merge: dashboard cinema foundation (lane 1/5)

This commit is contained in:
Sam Valladares 2026-06-24 17:34:47 -05:00
commit f80c51ee59
38 changed files with 6160 additions and 604 deletions

View file

@ -1,3 +1,30 @@
# 🚀 MANDATE #0 — MAXIMUM AMBITION. NO HEDGING. (read this first, every time)
This is the standing default for ALL work on Vestige, before anything else below.
- **Assume maximum ambition.** Build the most exhaustive, jaw-dropping, correct
version — not the safe "good enough" one. The ceiling is "nobody else has
this," not "this works." Default to the winning move, not the cautious one.
- **Scour before you settle.** When unsure or stuck, research the real frontier
(web, source, the actual running app) before acting. Never accept the first
adequate answer when a deeper one exists. Verify, don't assume.
- **No hedging.** Lead with the best path and a clear recommendation. Forbidden:
"this is probably too risky," "temper expectations," "good enough," "maybe try
the easier one." Risks get their own honest section — never used to shrink the
target.
- **Show proof.** Verify changes in the real running app and share the evidence
(screenshots, test output, gate results) — don't claim done without it.
- **Protect what's flawless, detonate what isn't.** Treat finished, loved work as
load-bearing (don't break it); push everything else past where any other dev
would stop.
Origin: Sam, Jun 22 2026 — the overnight session that turned the dashboard +
Memory Cinema from "alive" into a category-of-one particle journey. The depth
only happened because the bar was set to maximum. Make that the default, not the
exception.
---
# Vestige Agent Guidance
This file is intentionally safe for the public repository. It gives coding

View file

@ -43,6 +43,43 @@ html {
font-family: var(--font-mono);
}
/*
OKLCH / DISPLAY-P3 ACCENT PALETTE (PROGRESSIVE ENHANCEMENT)
The @theme block above keeps the original sRGB hex values, which
Tailwind reads at build time and which serve as the fallback for
sRGB monitors and browsers without OKLCH support.
When the browser understands oklch(), we redefine the SAME vivid
accents + node-type colors using their OKLCH equivalents. These are
faithful conversions of the hex values (same hue/chroma identity);
on a wide-gamut display-p3 monitor they render more saturated while
reading as the same color. The void/abyss/surface neutrals are left
untouched only the vivid accents benefit from the wider gamut. */
@supports (color: oklch(0 0 0)) {
:root {
/* Accent colors */
--color-synapse: oklch(0.585 0.222 277);
--color-synapse-glow: oklch(0.685 0.169 277);
--color-dream: oklch(0.627 0.265 304);
--color-dream-glow: oklch(0.714 0.203 305);
--color-memory: oklch(0.623 0.214 259);
--color-recall: oklch(0.696 0.17 162);
--color-decay: oklch(0.637 0.237 25);
--color-warning: oklch(0.769 0.188 70);
/* Node type colors */
--color-node-fact: oklch(0.623 0.214 259);
--color-node-concept: oklch(0.606 0.25 292);
--color-node-event: oklch(0.769 0.188 70);
--color-node-person: oklch(0.696 0.17 162);
--color-node-place: oklch(0.715 0.143 215);
--color-node-note: oklch(0.551 0.027 264);
--color-node-pattern: oklch(0.656 0.241 354);
--color-node-decision: oklch(0.637 0.237 25);
}
}
body {
margin: 0;
min-height: 100vh;
@ -234,3 +271,304 @@ body {
.retention-low { color: var(--color-warning); }
.retention-good { color: var(--color-recall); }
.retention-strong { color: var(--color-synapse); }
/*
VIEW TRANSITIONS (CROSSFADE)
Native View Transitions API crossfade between routes. Pairs with the
onNavigate hook in +layout.svelte that calls document.startViewTransition.
Reduced-motion users get an instant cut (the @media guard disables the
animation entirely, so the default browser cross-fade does not run). */
@media not (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 180ms;
animation-timing-function: ease;
}
::view-transition-old(root) {
animation-name: vt-fade-out;
}
::view-transition-new(root) {
animation-name: vt-fade-in;
}
@keyframes vt-fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes vt-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
}
/*
THE "ALIVE" LAYER (bleeding-edge, progressively enhanced)
Shared primitives that make every page breathe, respond, and feel seen.
Everything here degrades gracefully and is disabled under reduced-motion.
*/
/* Registered animatable custom props (@property)
Registering a property as <angle>/<percentage> lets the browser TWEEN it,
which plain CSS vars can't do. Powers the rotating conic borders below. */
@property --angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
@property --shine {
syntax: '<percentage>';
initial-value: 0%;
inherits: false;
}
/* Scroll-reveal (paired with use:reveal)
Elements start translated + transparent; .reveal-in lands them. The
action toggles the class via IntersectionObserver, with per-item --reveal-delay
for staggered list entrances. */
.reveal {
opacity: 0;
transform: translateY(var(--reveal-y, 16px));
transition:
opacity 0.55s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.55s cubic-bezier(0.22, 1, 0.36, 1);
transition-delay: var(--reveal-delay, 0ms);
will-change: opacity, transform;
}
.reveal-in {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.reveal {
opacity: 1;
transform: none;
transition: none;
}
}
/* @starting-style page/section entry
Native entry animation with zero JS: the element animates FROM the
starting-style the first time it's rendered. Used on route content. */
@media not (prefers-reduced-motion: reduce) {
.enter {
transition:
opacity 0.4s ease,
transform 0.4s cubic-bezier(0.22, 1, 0.36, 1);
}
@starting-style {
.enter {
opacity: 0;
transform: translateY(10px);
}
}
}
/* Conic-gradient animated border (premium "live" frame)
A slowly rotating iridescent ring around a panel. The @property --angle
makes the conic gradient's start angle tweenable. Use class .live-border. */
.live-border {
position: relative;
isolation: isolate;
}
.live-border::before {
content: '';
position: absolute;
inset: -1px;
border-radius: inherit;
padding: 1px;
background: conic-gradient(
from var(--angle),
transparent 0%,
var(--color-synapse) 18%,
var(--color-dream) 33%,
transparent 50%,
transparent 100%
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
opacity: 0.6;
z-index: -1;
}
@media not (prefers-reduced-motion: reduce) {
.live-border::before {
animation: border-rotate 6s linear infinite;
}
@keyframes border-rotate {
to {
--angle: 360deg;
}
}
}
/* Cursor spotlight surface (paired with use:spotlight)
A soft radial glow follows the pointer across a panel. --spot-x/y/o are
set by the action; default opacity 0 so it's invisible until hovered. */
.spotlight-surface {
position: relative;
overflow: hidden;
}
.spotlight-surface::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(
340px circle at var(--spot-x, 50%) var(--spot-y, 50%),
rgba(129, 140, 248, 0.12),
transparent 60%
);
opacity: var(--spot-o, 0);
transition: opacity 0.3s ease;
z-index: 0;
}
/* ── Tilt glare (paired with use:tilt glare) ────────────────────────────── */
.tilt-glare {
position: relative;
overflow: hidden;
}
.tilt-glare::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(
circle at var(--glare-x, 50%) var(--glare-y, 50%),
rgba(255, 255, 255, 0.14),
transparent 45%
);
opacity: var(--glare-o, 0);
transition: opacity 0.3s ease;
}
/* Breathing live indicator (calmer than a hard blink)
A dot that gently inhales/exhales scale + glow reads as "alive,
listening" rather than "error blinking". */
.breathe {
animation: breathe 3.2s ease-in-out infinite;
}
@keyframes breathe {
0%, 100% {
transform: scale(1);
filter: drop-shadow(0 0 2px currentColor);
opacity: 0.85;
}
50% {
transform: scale(1.18);
filter: drop-shadow(0 0 7px currentColor);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.breathe { animation: none; }
}
/* A live "radar ping" ring that expands and fades — wrap a dot in .ping-host. */
.ping-host {
position: relative;
}
.ping-host::before {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: currentColor;
opacity: 0.6;
z-index: -1;
}
@media not (prefers-reduced-motion: reduce) {
.ping-host::before {
animation: ping 2.4s cubic-bezier(0, 0, 0.2, 1) infinite;
}
@keyframes ping {
0% { transform: scale(1); opacity: 0.5; }
80%, 100% { transform: scale(2.6); opacity: 0; }
}
}
/* Shimmer skeleton (loading that feels intentional, not frozen)
A diagonal light sweep over skeleton blocks. Use .shimmer on the
placeholder; it replaces a plain pulse with a directional sheen. */
.shimmer {
position: relative;
overflow: hidden;
background: rgba(255, 255, 255, 0.03);
}
.shimmer::after {
content: '';
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(
90deg,
transparent,
rgba(129, 140, 248, 0.12),
transparent
);
}
@media not (prefers-reduced-motion: reduce) {
.shimmer::after {
animation: shimmer 1.6s ease-in-out infinite;
}
@keyframes shimmer {
100% { transform: translateX(100%); }
}
}
/* ── Gradient text that slowly drifts (alive headings) ──────────────────── */
.text-aurora {
background: linear-gradient(
100deg,
var(--color-synapse-glow),
var(--color-dream-glow),
var(--color-recall),
var(--color-synapse-glow)
);
background-size: 250% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
@media not (prefers-reduced-motion: reduce) {
.text-aurora {
animation: aurora-drift 8s ease-in-out infinite;
}
@keyframes aurora-drift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
}
/* ── Hover lift utility — cards rise + glow when pointed at ───────────────*/
.lift {
transition:
transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1),
box-shadow 0.28s ease,
border-color 0.28s ease;
}
.lift:hover {
transform: translateY(-3px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(99, 102, 241, 0.18);
}
@media (prefers-reduced-motion: reduce) {
.lift:hover { transform: none; }
}
/* ── Tabular numerals helper (count-ups don't jitter) ──────────────────── */
.tabular-nums {
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum';
}
/* ── Focus ring consistency (accessible + on-brand) ─────────────────────── */
:where(button, a, input, select, [role='button'], [tabindex]):focus-visible {
outline: 2px solid var(--color-synapse-glow);
outline-offset: 2px;
border-radius: 0.4rem;
}

View file

@ -0,0 +1,136 @@
// ═══════════════════════════════════════════════════════════════════════
// Micro-interaction actions — the "alive" layer for pointer feel.
// ───────────────────────────────────────────────────────────────────────
// All actions no-op under prefers-reduced-motion and clean up their own
// listeners on destroy. Pure pointer events, no dependency.
// ═══════════════════════════════════════════════════════════════════════
const prefersReducedMotion = () =>
typeof window !== 'undefined' &&
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
// ── magnetic: element drifts toward the cursor, snaps back on leave ──────
export interface MagneticOptions {
/** How far the element is allowed to drift, in px. */
strength?: number;
}
export function magnetic(node: HTMLElement, options: MagneticOptions = {}) {
if (prefersReducedMotion()) return {};
const strength = options.strength ?? 8;
let raf = 0;
function onMove(e: PointerEvent) {
const r = node.getBoundingClientRect();
const mx = e.clientX - (r.left + r.width / 2);
const my = e.clientY - (r.top + r.height / 2);
const dx = Math.max(-1, Math.min(1, mx / (r.width / 2))) * strength;
const dy = Math.max(-1, Math.min(1, my / (r.height / 2))) * strength;
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
node.style.transform = `translate(${dx}px, ${dy}px)`;
});
}
function onLeave() {
cancelAnimationFrame(raf);
node.style.transform = '';
}
node.style.transition = 'transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)';
node.addEventListener('pointermove', onMove);
node.addEventListener('pointerleave', onLeave);
return {
destroy() {
node.removeEventListener('pointermove', onMove);
node.removeEventListener('pointerleave', onLeave);
cancelAnimationFrame(raf);
},
};
}
// ── tilt: 3D parallax tilt toward the cursor (cards, hero panels) ────────
export interface TiltOptions {
/** Max tilt in degrees. */
max?: number;
/** Lift the card toward the viewer on hover, in px. */
lift?: number;
/** Add a moving sheen highlight that follows the cursor. */
glare?: boolean;
}
export function tilt(node: HTMLElement, options: TiltOptions = {}) {
if (prefersReducedMotion()) return {};
const max = options.max ?? 6;
const lift = options.lift ?? 0;
const glare = options.glare ?? false;
let raf = 0;
node.style.transformStyle = 'preserve-3d';
node.style.transition = 'transform 0.3s cubic-bezier(0.23, 1, 0.32, 1)';
if (glare) {
node.style.setProperty('--glare-x', '50%');
node.style.setProperty('--glare-y', '50%');
node.style.setProperty('--glare-o', '0');
}
function onMove(e: PointerEvent) {
const r = node.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width;
const py = (e.clientY - r.top) / r.height;
const rx = (0.5 - py) * max * 2;
const ry = (px - 0.5) * max * 2;
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
node.style.transform = `perspective(900px) rotateX(${rx}deg) rotateY(${ry}deg)${lift ? ` translateZ(${lift}px)` : ''}`;
if (glare) {
node.style.setProperty('--glare-x', `${px * 100}%`);
node.style.setProperty('--glare-y', `${py * 100}%`);
node.style.setProperty('--glare-o', '1');
}
});
}
function onLeave() {
cancelAnimationFrame(raf);
node.style.transform = '';
if (glare) node.style.setProperty('--glare-o', '0');
}
node.addEventListener('pointermove', onMove);
node.addEventListener('pointerleave', onLeave);
return {
destroy() {
node.removeEventListener('pointermove', onMove);
node.removeEventListener('pointerleave', onLeave);
cancelAnimationFrame(raf);
},
};
}
// ── spotlight: a soft radial glow that tracks the cursor over a surface ──
// Sets --spot-x / --spot-y custom props the element's CSS can read to
// position a radial-gradient highlight. Makes large panels feel responsive
// to the pointer even when nothing is "hovered".
export function spotlight(node: HTMLElement) {
if (prefersReducedMotion()) return {};
let raf = 0;
function onMove(e: PointerEvent) {
const r = node.getBoundingClientRect();
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
node.style.setProperty('--spot-x', `${e.clientX - r.left}px`);
node.style.setProperty('--spot-y', `${e.clientY - r.top}px`);
node.style.setProperty('--spot-o', '1');
});
}
function onLeave() {
node.style.setProperty('--spot-o', '0');
}
node.addEventListener('pointermove', onMove);
node.addEventListener('pointerleave', onLeave);
return {
destroy() {
node.removeEventListener('pointermove', onMove);
node.removeEventListener('pointerleave', onLeave);
cancelAnimationFrame(raf);
},
};
}

View file

@ -0,0 +1,68 @@
// ═══════════════════════════════════════════════════════════════════════
// reveal — scroll-into-view entrance animation as a Svelte action.
// ───────────────────────────────────────────────────────────────────────
// Usage: <div use:reveal> // default rise+fade
// <div use:reveal={{ y: 24, delay: 80, once: true }}>
//
// Adds an IntersectionObserver that flips the element to its "revealed"
// state the first time it scrolls into view. Pairs with the .reveal /
// .reveal-in CSS in app.css. Honors prefers-reduced-motion by revealing
// instantly (no transform), so motion-sensitive users still see content.
//
// Why an action and not CSS scroll-timeline alone: scroll-driven
// animation-timeline is great for continuous scrubbing, but for a simple
// "appear once when seen" we want a one-shot that also works as a staggered
// list entrance with per-item delay — an observer is the robust path with
// universal mid-2026 support.
// ═══════════════════════════════════════════════════════════════════════
export interface RevealOptions {
/** Pixels to translate up from on entry. */
y?: number;
/** ms delay before the transition starts (use for stagger). */
delay?: number;
/** Only reveal once, then stop observing (default true). */
once?: boolean;
/** 0-1 visibility threshold to trigger. */
threshold?: number;
}
const prefersReducedMotion = () =>
typeof window !== 'undefined' &&
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
export function reveal(node: HTMLElement, options: RevealOptions = {}) {
const { y = 16, delay = 0, once = true, threshold = 0.12 } = options;
// Motion-off: show immediately, do nothing else.
if (prefersReducedMotion()) {
node.classList.add('reveal-in');
return {};
}
node.classList.add('reveal');
node.style.setProperty('--reveal-y', `${y}px`);
if (delay) node.style.setProperty('--reveal-delay', `${delay}ms`);
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
node.classList.add('reveal-in');
if (once) io.unobserve(node);
} else if (!once) {
node.classList.remove('reveal-in');
}
}
},
{ threshold, rootMargin: '0px 0px -8% 0px' }
);
io.observe(node);
return {
destroy() {
io.disconnect();
},
};
}

View file

@ -0,0 +1,91 @@
<script lang="ts">
// ═══════════════════════════════════════════════════════════════════
// ANIMATED NUMBER — smooth count-up / tween between values.
// ───────────────────────────────────────────────────────────────────
// Tweens from the previous value to the new one with an ease-out curve.
// Counts up on first mount (from 0) so every figure feels like it's
// being tallied live, and re-tweens whenever the bound value changes
// (e.g. a websocket push). Respects prefers-reduced-motion: those users
// get the final value instantly. Pure rAF — no dependency.
// ═══════════════════════════════════════════════════════════════════
interface Props {
value: number;
/** Decimal places to render. */
decimals?: number;
/** Multiply before formatting (e.g. 100 for a 0-1 ratio → percent). */
scale?: number;
prefix?: string;
suffix?: string;
duration?: number;
class?: string;
/** Group thousands with separators. */
group?: boolean;
}
let {
value,
decimals = 0,
scale = 1,
prefix = '',
suffix = '',
duration = 900,
class: cls = '',
group = true,
}: Props = $props();
let display = $state(0);
let raf = 0;
let from = 0;
let start = 0;
let mounted = false;
const reduceMotion =
typeof window !== 'undefined' &&
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
function easeOutExpo(t: number): number {
return t === 1 ? 1 : 1 - Math.pow(2, -10 * t);
}
function animateTo(target: number) {
if (reduceMotion) {
display = target;
return;
}
cancelAnimationFrame(raf);
from = display;
start = 0;
function tick(ts: number) {
if (!start) start = ts;
const t = Math.min(1, (ts - start) / duration);
display = from + (target - from) * easeOutExpo(t);
if (t < 1) raf = requestAnimationFrame(tick);
else display = target;
}
raf = requestAnimationFrame(tick);
}
$effect(() => {
// Track the incoming value; tween toward it.
const target = value;
if (!mounted) {
mounted = true;
animateTo(target);
} else {
animateTo(target);
}
return () => cancelAnimationFrame(raf);
});
let formatted = $derived(
(() => {
const v = display * scale;
const fixed = v.toFixed(decimals);
if (!group) return fixed;
const [int, frac] = fixed.split('.');
const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return frac !== undefined ? `${grouped}.${frac}` : grouped;
})()
);
</script>
<span class="tabular-nums {cls}">{prefix}{formatted}{suffix}</span>

View file

@ -0,0 +1,338 @@
<script lang="ts" module>
export interface DropdownOption {
value: string;
label: string;
/** Optional color dot (e.g. node-type color) shown before the label. */
color?: string;
/** Optional small count/badge shown after the label. */
badge?: string | number;
/** Optional icon name from the Icon system. */
icon?: IconName;
}
</script>
<script lang="ts">
import Icon, { type IconName } from './Icon.svelte';
// ═══════════════════════════════════════════════════════════════════
// DROPDOWN — accessible, animated, themed select replacement.
// ───────────────────────────────────────────────────────────────────
// Replaces the dead native <select>. Keyboard-navigable (↑/↓/Enter/Esc/
// Home/End/type-ahead), closes on outside click, animates open with a
// spring-ish scale, and themes to the cosmic palette. Makes filtering
// CLEAR: shows the current value, an icon, color dots, and counts.
// ═══════════════════════════════════════════════════════════════════
interface Props {
options: DropdownOption[];
value: string;
label?: string;
placeholder?: string;
icon?: IconName;
/** Width hint for the trigger; menu matches the trigger width. */
class?: string;
onChange?: (value: string) => void;
}
let {
options,
value = $bindable(),
label,
placeholder = 'Select…',
icon,
class: cls = '',
onChange,
}: Props = $props();
let open = $state(false);
let triggerEl = $state<HTMLButtonElement>();
let menuEl = $state<HTMLDivElement>();
let activeIndex = $state(-1);
let typeAhead = '';
let typeAheadTimer: ReturnType<typeof setTimeout>;
let selected = $derived(options.find((o) => o.value === value));
function select(opt: DropdownOption) {
value = opt.value;
onChange?.(opt.value);
close();
triggerEl?.focus();
}
function toggle() {
open ? close() : openMenu();
}
function openMenu() {
open = true;
activeIndex = Math.max(0, options.findIndex((o) => o.value === value));
requestAnimationFrame(() => {
(menuEl?.querySelectorAll('[role="option"]')[activeIndex] as HTMLElement)?.scrollIntoView({
block: 'nearest',
});
});
}
function close() {
open = false;
activeIndex = -1;
}
function onTriggerKey(e: KeyboardEvent) {
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
open ? move(1) : openMenu();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
open ? move(-1) : openMenu();
} else if (e.key === 'Escape') {
close();
} else if (e.key === 'Home' && open) {
e.preventDefault();
activeIndex = 0;
} else if (e.key === 'End' && open) {
e.preventDefault();
activeIndex = options.length - 1;
} else if (open && e.key === 'Enter' && activeIndex >= 0) {
e.preventDefault();
select(options[activeIndex]);
} else if (e.key.length === 1 && /\S/.test(e.key)) {
// type-ahead
if (!open) openMenu();
clearTimeout(typeAheadTimer);
typeAhead += e.key.toLowerCase();
typeAheadTimer = setTimeout(() => (typeAhead = ''), 600);
const idx = options.findIndex((o) => o.label.toLowerCase().startsWith(typeAhead));
if (idx >= 0) activeIndex = idx;
}
}
function move(delta: number) {
const n = options.length;
activeIndex = (activeIndex + delta + n) % n;
requestAnimationFrame(() => {
(menuEl?.querySelectorAll('[role="option"]')[activeIndex] as HTMLElement)?.scrollIntoView({
block: 'nearest',
});
});
}
// Close on outside click while open.
$effect(() => {
if (!open) return;
function onDocClick(e: MouseEvent) {
if (!triggerEl?.contains(e.target as Node) && !menuEl?.contains(e.target as Node)) close();
}
document.addEventListener('click', onDocClick, true);
return () => document.removeEventListener('click', onDocClick, true);
});
</script>
<div class="dd {cls}">
{#if label}
<span class="dd-label">{label}</span>
{/if}
<button
bind:this={triggerEl}
type="button"
class="dd-trigger"
class:dd-open={open}
aria-haspopup="listbox"
aria-expanded={open}
onclick={toggle}
onkeydown={onTriggerKey}
>
{#if icon}
<span class="dd-trigger-icon"><Icon name={icon} size={15} /></span>
{/if}
<span class="dd-value" class:dd-placeholder={!selected}>
{#if selected?.color}
<span class="dd-dot" style="background:{selected.color}"></span>
{/if}
{selected ? selected.label : placeholder}
</span>
<span class="dd-chevron" class:dd-chevron-open={open}><Icon name="chevron" size={14} /></span>
</button>
{#if open}
<div
bind:this={menuEl}
class="dd-menu glass-panel"
role="listbox"
tabindex="-1"
aria-label={label ?? placeholder}
>
{#each options as opt, i (opt.value)}
<button
type="button"
role="option"
aria-selected={opt.value === value}
class="dd-option"
class:dd-active={i === activeIndex}
class:dd-selected={opt.value === value}
onmouseenter={() => (activeIndex = i)}
onclick={() => select(opt)}
>
{#if opt.icon}<span class="dd-opt-icon"><Icon name={opt.icon} size={15} /></span>{/if}
{#if opt.color}<span class="dd-dot" style="background:{opt.color}"></span>{/if}
<span class="dd-opt-label">{opt.label}</span>
{#if opt.badge !== undefined}<span class="dd-badge">{opt.badge}</span>{/if}
{#if opt.value === value}<span class="dd-check"><Icon name="sparkle" size={12} /></span>{/if}
</button>
{/each}
</div>
{/if}
</div>
<style>
.dd {
position: relative;
display: inline-flex;
flex-direction: column;
gap: 0.3rem;
}
.dd-label {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-muted);
padding-left: 0.15rem;
}
.dd-trigger {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.7rem 0.55rem 0.8rem;
min-width: 9rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(99, 102, 241, 0.12);
border-radius: 0.75rem;
color: var(--color-text);
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
backdrop-filter: blur(8px);
transition:
border-color 0.2s ease,
background 0.2s ease,
box-shadow 0.2s ease;
}
.dd-trigger:hover {
border-color: rgba(99, 102, 241, 0.3);
background: rgba(255, 255, 255, 0.05);
}
.dd-trigger:focus-visible,
.dd-trigger.dd-open {
outline: none;
border-color: rgba(99, 102, 241, 0.5);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12);
}
.dd-trigger-icon {
color: var(--color-synapse-glow);
display: inline-flex;
}
.dd-value {
flex: 1;
text-align: left;
display: inline-flex;
align-items: center;
gap: 0.45rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dd-placeholder {
color: var(--color-muted);
}
.dd-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 0 6px currentColor;
}
.dd-chevron {
color: var(--color-dim);
display: inline-flex;
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.dd-chevron-open {
transform: rotate(180deg);
color: var(--color-synapse-glow);
}
.dd-menu {
position: absolute;
top: calc(100% + 0.4rem);
left: 0;
z-index: 60;
min-width: 100%;
max-height: 18rem;
overflow-y: auto;
padding: 0.35rem;
border-radius: 0.85rem;
transform-origin: top center;
}
@media not (prefers-reduced-motion: reduce) {
.dd-menu {
animation: dd-pop 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes dd-pop {
from {
opacity: 0;
transform: translateY(-6px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
}
.dd-option {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.6rem;
border: none;
background: transparent;
color: var(--color-dim);
font-size: 0.8rem;
font-family: inherit;
text-align: left;
border-radius: 0.6rem;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
}
.dd-active {
background: rgba(99, 102, 241, 0.14);
color: var(--color-text);
}
.dd-selected {
color: var(--color-synapse-glow);
}
.dd-opt-icon {
color: var(--color-dim);
display: inline-flex;
}
.dd-active .dd-opt-icon {
color: var(--color-synapse-glow);
}
.dd-opt-label {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dd-badge {
font-size: 0.65rem;
font-variant-numeric: tabular-nums;
color: var(--color-muted);
background: rgba(255, 255, 255, 0.05);
padding: 0.05rem 0.4rem;
border-radius: 0.4rem;
}
.dd-check {
color: var(--color-synapse-glow);
display: inline-flex;
}
</style>

View file

@ -50,6 +50,20 @@
let ctx: SceneContext;
let animationId: number;
// Accessibility: honour the OS "reduce motion" setting. The dominant
// continuous motion in the graph is the camera auto-rotate; disabling it
// removes the vestibular-trigger while keeping the graph fully usable
// (manual orbit, hover, selection, live events all still work). Tracked
// reactively so a mid-session OS toggle is respected.
let prefersReducedMotion =
typeof window !== 'undefined' &&
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
let reducedMotionMq: MediaQueryList | null = null;
function onReducedMotionChange(e: MediaQueryListEvent) {
prefersReducedMotion = e.matches;
if (ctx?.controls) ctx.controls.autoRotate = !prefersReducedMotion;
}
// Modules
let nodeManager: NodeManager;
let edgeManager: EdgeManager;
@ -74,6 +88,15 @@
onMount(() => {
ctx = createScene(container);
// Respect reduced-motion: the scene defaults to auto-rotate on; turn it
// off up front for users who asked for less motion, and listen for live
// OS-setting changes.
if (prefersReducedMotion) ctx.controls.autoRotate = false;
if (typeof window !== 'undefined' && window.matchMedia) {
reducedMotionMq = window.matchMedia('(prefers-reduced-motion: reduce)');
reducedMotionMq.addEventListener?.('change', onReducedMotionChange);
}
// Nebula background
const nebula = createNebulaBackground(ctx.scene);
nebulaMaterial = nebula.material;
@ -113,6 +136,7 @@
onDestroy(() => {
cancelAnimationFrame(animationId);
window.removeEventListener('resize', onResize);
reducedMotionMq?.removeEventListener?.('change', onReducedMotionChange);
container?.removeEventListener('pointermove', onPointerMove);
container?.removeEventListener('click', onClick);
effects?.dispose();

View file

@ -0,0 +1,143 @@
<script lang="ts" module>
// ═══════════════════════════════════════════════════════════════════
// VESTIGE ICON SYSTEM
// ───────────────────────────────────────────────────────────────────
// Hand-tuned inline SVG icons — ZERO runtime dependency. Every nav
// item gets a UNIQUE, semantically-meaningful silhouette that reads
// instantly even at 18px. All strokes use `currentColor` so a single
// parent `color` (or our glow utilities) themes the whole set.
//
// Design language: 24×24 viewBox, 1.6 stroke, round caps/joins, a
// cosmic/neural motif. Distinct silhouettes are the contract — no two
// icons may be confused at a glance (the old Unicode set reused
// ◎ ◈ ◉ ◷ across multiple items; that bug is dead here).
// ═══════════════════════════════════════════════════════════════════
export type IconName =
| 'graph'
| 'reasoning'
| 'memories'
| 'timeline'
| 'feed'
| 'explore'
| 'activation'
| 'dreams'
| 'schedule'
| 'importance'
| 'duplicates'
| 'contradictions'
| 'patterns'
| 'intentions'
| 'stats'
| 'settings'
| 'command'
| 'search'
| 'filter'
| 'sparkle'
| 'chevron'
| 'close'
| 'pulse'
| 'logo';
// Each entry is the inner markup of a 24×24 SVG. Strokes inherit
// currentColor; fills are explicit where a solid accent reads better.
export const ICON_PATHS: Record<IconName, string> = {
// Connected nodes — a literal knowledge graph.
graph: `<circle cx="6" cy="7" r="2.1"/><circle cx="18" cy="6" r="2.1"/><circle cx="12" cy="17.5" r="2.3"/><path d="M7.7 8.4 10.6 15.5M16.4 7.6 13.2 15.6M8 7l8-1"/>`,
// Branching logic tree with a spark — deduction.
reasoning: `<path d="M12 3v4M12 7 7 11M12 7l5 4"/><circle cx="12" cy="3" r="1.6"/><circle cx="7" cy="12" r="1.8"/><circle cx="17" cy="12" r="1.8"/><path d="m7 16 .9 1.9 1.9.6-1.4 1.5.2 2-1.6-1-1.6 1 .2-2L4.2 18.5l1.9-.6z" fill="currentColor" stroke="none" opacity=".55"/>`,
// Stacked memory cards / layered recall.
memories: `<rect x="5" y="8" width="14" height="11" rx="2.2"/><path d="M7.5 8V6.4A1.4 1.4 0 0 1 8.9 5h8.2A1.9 1.9 0 0 1 19 7v8.5"/><path d="M9 12.5h6M9 15.5h4"/>`,
// Time axis with marked moments.
timeline: `<path d="M3 12h18"/><circle cx="7" cy="12" r="2" fill="currentColor" stroke="none"/><circle cx="13" cy="12" r="2.2"/><circle cx="19" cy="12" r="1.6" fill="currentColor" stroke="none"/><path d="M7 12V7M13 12v6M19 12V9"/>`,
// Streaming feed lines.
feed: `<path d="M4 7h16M4 12h16M4 17h10"/><circle cx="18.5" cy="17" r="1.4" fill="currentColor" stroke="none"/>`,
// Compass — exploration.
explore: `<circle cx="12" cy="12" r="9"/><path d="m15.5 8.5-2 5-5 2 2-5z" fill="currentColor" stroke="none" opacity=".8"/><circle cx="12" cy="12" r="1" fill="var(--color-void,#050510)" stroke="none"/>`,
// Radiating activation — spreading energy from a core.
activation: `<circle cx="12" cy="12" r="2.4" fill="currentColor" stroke="none"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1"/>`,
// Crescent moon + stars — dream consolidation.
dreams: `<path d="M20 13.5A7.5 7.5 0 1 1 11 4.2a6 6 0 0 0 9 9.3Z"/><path d="m6.5 6 .5 1.4L8.4 8 7 8.6 6.5 10 6 8.6 4.6 8 6 7.4z" fill="currentColor" stroke="none" opacity=".7"/>`,
// Calendar — scheduling.
schedule: `<rect x="4" y="5.5" width="16" height="14" rx="2"/><path d="M4 9.5h16M8 3.5v4M16 3.5v4"/><circle cx="9" cy="14" r="1" fill="currentColor" stroke="none"/><circle cx="13" cy="14" r="1" fill="currentColor" stroke="none"/><circle cx="9" cy="17" r="1" fill="currentColor" stroke="none"/>`,
// Filled award star — importance.
importance: `<path d="M12 3.5l2.6 5.3 5.9.9-4.3 4.1 1 5.8L12 17l-5.2 2.6 1-5.8L3.5 9.7l5.9-.9z" fill="currentColor" stroke="none"/>`,
// Two overlapping cards — duplicates.
duplicates: `<rect x="4" y="7" width="11" height="11" rx="2"/><path d="M9 7V6a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-1"/>`,
// Opposing arrows colliding — contradiction.
contradictions: `<path d="M3 9h8l-2.5-2.5M3 9l2.5 2.5M21 15h-8l2.5 2.5M21 15l-2.5-2.5"/><path d="M12 4v16" opacity=".35" stroke-dasharray="2 2.5"/>`,
// Tessellated grid — recurring patterns.
patterns: `<path d="M4 8.5 8.5 4 13 8.5 8.5 13zM13 8.5 17.5 4 22 8.5 17.5 13zM8.5 17 13 12.5 17.5 17 13 21.5z" opacity=".9"/>`,
// Crosshair / target — intentions / aim.
intentions: `<circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3.4"/><circle cx="12" cy="12" r="1" fill="currentColor" stroke="none"/><path d="M12 1.5v3M12 19.5v3M1.5 12h3M19.5 12h3"/>`,
// Bar chart — stats.
stats: `<path d="M4 20h16"/><rect x="5.5" y="11" width="3.2" height="6.5" rx="1" fill="currentColor" stroke="none"/><rect x="10.4" y="7" width="3.2" height="10.5" rx="1" fill="currentColor" stroke="none" opacity=".85"/><rect x="15.3" y="13.5" width="3.2" height="4" rx="1" fill="currentColor" stroke="none" opacity=".7"/>`,
// Gear — settings.
settings: `<circle cx="12" cy="12" r="3.2"/><path d="M12 2.5v2.6M12 18.9v2.6M21.5 12h-2.6M5.1 12H2.5M18.7 5.3l-1.9 1.9M7.2 16.8l-1.9 1.9M18.7 18.7l-1.9-1.9M7.2 7.2 5.3 5.3"/>`,
// ── UI utility glyphs (not nav, but part of the system) ──
command: `<path d="M9 6.5A2.5 2.5 0 1 0 6.5 9H9V6.5ZM9 9v6M9 9h6M15 9V6.5A2.5 2.5 0 1 1 17.5 9H15ZM15 15h2.5A2.5 2.5 0 1 1 15 17.5V15ZM15 15H9M9 15v2.5A2.5 2.5 0 1 1 6.5 15H9Z"/>`,
search: `<circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.3-4.3"/>`,
filter: `<path d="M4 5.5h16l-6 7v5l-4 2v-7z"/>`,
sparkle: `<path d="M12 3l1.8 5.4L19 10l-5.2 1.6L12 17l-1.8-5.4L5 10l5.2-1.6z" fill="currentColor" stroke="none"/><path d="m18.5 14 .7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7z" fill="currentColor" stroke="none" opacity=".6"/>`,
chevron: `<path d="m8 10 4 4 4-4"/>`,
close: `<path d="m6 6 12 12M18 6 6 18"/>`,
pulse: `<path d="M3 12h4l2-6 4 12 2-6h6"/>`,
logo: `<circle cx="6" cy="8" r="2"/><circle cx="17" cy="6.5" r="2"/><circle cx="12" cy="17" r="2.4" fill="currentColor" stroke="none"/><circle cx="18" cy="16" r="1.6"/><path d="M7.7 9.2 10.5 15M15.3 7.4 12.7 15M8 8l7-1M14 16.5l2.4-.3"/>`,
};
</script>
<script lang="ts">
interface Props {
name: IconName;
size?: number;
strokeWidth?: number;
class?: string;
/** When true, the icon plays a one-shot stroke-draw on mount. */
draw?: boolean;
}
let { name, size = 20, strokeWidth = 1.6, class: cls = '', draw = false }: Props = $props();
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width={size}
height={size}
fill="none"
stroke="currentColor"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
class="vestige-icon {draw ? 'vestige-icon-draw' : ''} {cls}"
aria-hidden="true"
role="presentation"
>
{@html ICON_PATHS[name]}
</svg>
<style>
.vestige-icon {
display: inline-block;
flex-shrink: 0;
vertical-align: middle;
overflow: visible;
}
/* One-shot stroke-draw — used on page-title icons so each route entry
feels like the icon is being "written" for you. Reduced-motion users
get the fully-drawn icon with no animation. */
@media not (prefers-reduced-motion: reduce) {
.vestige-icon-draw :global(path),
.vestige-icon-draw :global(circle),
.vestige-icon-draw :global(rect) {
stroke-dasharray: 64;
stroke-dashoffset: 64;
animation: icon-draw 0.7s cubic-bezier(0.65, 0, 0.35, 1) forwards;
}
@keyframes icon-draw {
to {
stroke-dashoffset: 0;
}
}
}
</style>

View file

@ -0,0 +1,831 @@
<script lang="ts">
// Memory Cinema — the orchestration layer (Phase 4).
//
// Ties the three tiers together into one fullscreen experience:
// Tier 3 (always): BFS pathfinder + camera director drive the flythrough.
// Tier 2 (default): local structured captions from real node/edge data.
// Tier 1 (opt-in / when available): richer narration from a backend LLM
// endpoint, or the opt-in on-device model (lazy-loaded only on click).
// WebGPU storm renders if supported; otherwise camera + captions still play.
//
// Launches in an isolated overlay with its own canvas — the WebGL graph
// underneath is never touched.
import { onDestroy } from 'svelte';
import * as THREE from 'three';
import type { GraphNode, GraphEdge } from '$types';
import { planCinemaPath, type CinemaPath, type CinemaBeat } from '$lib/graph/cinema/pathfinder';
import { CinemaDirector } from '$lib/graph/cinema/director';
import {
resolveNarration,
localCaptions,
type CinemaNarration,
type BeatNarration,
} from '$lib/graph/cinema/narrator';
import { computeSignals } from '$lib/graph/cinema/topology';
import {
planShotsDeterministic,
resolveShots,
type DirectorPlan,
type ResolvedShot,
type StormMode,
} from '$lib/graph/cinema/auteur';
import type { SemanticRole } from '$lib/graph/cinema/storm';
import type { CinemaSandbox } from '$lib/graph/cinema/sandbox';
interface Props {
nodes: GraphNode[];
edges: GraphEdge[];
centerId: string;
/** Optional Tier-1 backend narration fetcher (passed when backend supports it). */
fetchBackendNarration?: () => Promise<BeatNarration[] | null>;
}
let { nodes, edges, centerId, fetchBackendNarration }: Props = $props();
let open = $state(false);
let stage = $state<'idle' | 'planning' | 'playing' | 'done'>('idle');
let caption = $state('');
let chip = $state('');
let progress = $state(0);
let beatIndex = $state(0);
let totalBeats = $state(0);
let narrationSource = $state<CinemaNarration['source'] | null>(null);
let webgpuActive = $state(false);
let voiceOn = $state(false);
let localAiOn = $state(false);
// Hide all UI chrome (top bar + captions) for a clean demo capture. Toggle
// with H. Default on; flip to off to record the pure storm with nothing
// blocking the visuals.
let cinemaChrome = $state(true);
let statusLine = $state('');
// Auteur (director) state surfaced in the overlay.
let directorNote = $state(''); // the current shot's "why" (cites a real metric)
let act = $state<'I' | 'II' | 'III'>('I');
let tension = $state(0); // 0..1 for the tension sparkline
let logline = $state('');
let plan = $state<DirectorPlan | null>(null);
let canvasHost = $state<HTMLDivElement | undefined>(undefined);
let sandbox: CinemaSandbox | null = null;
let director: CinemaDirector | null = null;
let path: CinemaPath | null = null;
let narration: CinemaNarration | null = null;
let rafId = 0;
let lastFrame = 0;
let typeTimer: ReturnType<typeof setInterval> | null = null;
let renderFailures = 0;
// ENDLESS DREAM MODE — after the tour ends, fire a new random crazier figure
// on this timer so the storm never sits idle. ($state so the template's
// "∞ dreaming" indicator reacts when it starts/stops.)
let dreamTimer = $state<ReturnType<typeof setInterval> | null>(null);
const reducedMotion =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// ── INTERACTIVE PARALLAX ────────────────────────────────────────────────
// Pointer/scroll/touch nudge the camera around the storm so the overlay feels
// like a live toy, then converge back to 0 when idle so the scripted film is
// never broken. None of this state is read in the template, so plain let/const
// is correct (no $state needed). Frame-rate-independent easing via `damp`.
const damp = (cur: number, tgt: number, lambda: number, dt: number) =>
cur + (tgt - cur) * (1 - Math.exp(-lambda * dt));
const pointer = { x: 0, y: 0 };
const camOff = { yaw: 0, pitch: 0 };
let zoomTarget = 0,
zoomLive = 0;
let lastInputAt = 0; // performance.now() of the last pointer/scroll/touch input
const IDLE_MS = 2500,
MAX_YAW = 0.35,
MAX_PITCH = 0.22;
const markInput = () => {
lastInputAt = performance.now();
};
// Deterministic layout: spread path nodes on a gentle spiral so the camera
// has distinct world positions to fly between (independent of the WebGL
// graph's internal coordinates — keeps the sandbox isolated).
// Lay the beat nodes out on a TIGHT, BOUNDED shell centered on the origin —
// fixed radius, no per-beat growth. Earlier this grew (22 + i*6) so each beat
// sat farther out and the camera+storm marched off into space ("flying off").
// A bounded shell keeps the whole composition centered; cinematic variety
// comes from the camera angle/move/standoff, not from translating across a
// huge volume. The focused node is always re-centered by recenterOn() below.
const SHELL_RADIUS = 14;
function layoutPositions(p: CinemaPath): Map<string, THREE.Vector3> {
const pos = new Map<string, THREE.Vector3>();
const n = p.beats.length;
for (let i = 0; i < n; i++) {
// Distribute beats evenly on a sphere (golden-angle spiral) so they
// never clump and never exceed SHELL_RADIUS from center.
const t = n > 1 ? i / (n - 1) : 0.5;
const y = 1 - t * 2; // 1..-1
const r = Math.sqrt(Math.max(0, 1 - y * y));
const theta = i * 2.399963; // golden angle
pos.set(
p.beats[i].nodeId,
new THREE.Vector3(
Math.cos(theta) * r * SHELL_RADIUS,
y * SHELL_RADIUS * 0.5,
Math.sin(theta) * r * SHELL_RADIUS
)
);
}
return pos;
}
function speak(text: string) {
if (!voiceOn || typeof speechSynthesis === 'undefined') return;
try {
speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text);
u.rate = 0.98;
u.pitch = 1.0;
speechSynthesis.speak(u);
} catch {
/* voice unavailable — captions carry it */
}
}
// Typewriter caption stream so text "arrives" with the camera.
function streamCaption(text: string) {
if (typeTimer) clearInterval(typeTimer);
caption = '';
if (reducedMotion) {
caption = text;
return;
}
let i = 0;
typeTimer = setInterval(() => {
caption = text.slice(0, ++i);
if (i >= text.length && typeTimer) {
clearInterval(typeTimer);
typeTimer = null;
}
}, 18);
}
// Map the director's StormMode to the storm runtime's SemanticRole. 'surprise'
// is a Phase-3 storm mode; until then it reads as 'connection'.
function stormRole(mode: StormMode): SemanticRole {
return mode === 'surprise' ? 'connection' : mode;
}
function onBeat(beat: CinemaBeat, index: number, shot: ResolvedShot | null) {
beatIndex = index + 1;
const text = narration?.beats[index]?.text ?? beat.node.label ?? '';
chip = narration?.beats[index]?.chip ?? '';
streamCaption(text);
speak(text);
// Surface the director's intent for this shot — the "why", act, tension.
if (shot) {
directorNote = shot.why;
act = shot.act;
tension = shot.tension;
}
if (sandbox && webgpuActive) {
const wp = currentPositions?.get(beat.nodeId);
if (wp) {
const mode: StormMode = shot?.stormMode ?? 'connection';
// Pass act + 0-based beat index so the storm holds Act I dimmer AND
// fades in extra-soft on beats 0/1 (which otherwise wash to white).
sandbox.transitionTo(stormRole(mode), wp, shot?.act ?? 'I', index);
}
// Drive flythrough strength from beat energy: high-tension beats fly
// THROUGH the storm (relaxed clamp + streak); reduced-motion = no streak.
const energy = shot?.tension ?? 0;
sandbox.setFlythrough(reducedMotion ? 0 : energy * 0.8);
}
}
let currentPositions: Map<string, THREE.Vector3> | null = null;
async function launch() {
// Tear down any prior run so Replay never inherits stale state.
cancelAnimationFrame(rafId);
stopDreamMode();
if (typeTimer) clearInterval(typeTimer);
director?.stop();
sandbox?.dispose();
sandbox = null;
director = null;
narration = null;
renderFailures = 0;
directorNote = '';
logline = '';
plan = null;
act = 'I';
tension = 0;
open = true;
stage = 'planning';
statusLine = 'Planning a path through your memory…';
caption = '';
chip = '';
progress = 0;
beatIndex = 0;
// Tier 3: plan the path (always works).
path = planCinemaPath(nodes, edges, centerId, 7);
totalBeats = path.beats.length;
if (totalBeats === 0) {
statusLine = 'Not enough memory to compose a tour yet.';
stage = 'done';
return;
}
currentPositions = layoutPositions(path);
// THE AUTEUR: read the graph's dramatic structure and direct the film.
// Tier 2 (deterministic) ships the hero; Tier 1 (LLM) lands in Phase 4.
const signals = computeSignals(nodes, edges);
plan = planShotsDeterministic(path, signals);
logline = plan.logline;
const shots = resolveShots(plan, path);
act = shots[0]?.act ?? 'I';
tension = shots[0]?.tension ?? 0;
// Tiers 1/2: resolve narration (backend LLM → local captions).
narration = await resolveNarration(path, localAiOn ? localAiFetcher() : fetchBackendNarration);
narrationSource = narration.source;
// Try the WebGPU storm; fall back silently to camera + captions.
webgpuActive = false;
if (canvasHost) {
try {
const { CinemaSandbox, isWebGPUSupported } = await import('$lib/graph/cinema/sandbox');
if (isWebGPUSupported()) {
sandbox = new CinemaSandbox(canvasHost);
await sandbox.boot();
webgpuActive = true;
}
} catch (e) {
console.warn('[cinema] WebGPU sandbox unavailable, camera-only mode:', e);
sandbox = null;
webgpuActive = false;
}
}
// Director drives the camera (sandbox camera if WebGPU, else a virtual one).
const fallbackAspect =
canvasHost && canvasHost.clientHeight > 0
? canvasHost.clientWidth / canvasHost.clientHeight
: 16 / 9;
const cam = sandbox?.cameraRef ?? new THREE.PerspectiveCamera(60, fallbackAspect, 0.1, 2000);
const target = sandbox?.target ?? new THREE.Vector3();
director = new CinemaDirector(cam, target, currentPositions, path, {
onBeat,
onProgress: (t) => (progress = t),
onComplete: () => {
stage = 'done';
statusLine =
reducedMotion || !webgpuActive
? 'End of tour.'
: '∞ Dreaming — endless generative figures';
startDreamMode();
},
}, { reducedMotion, shots, centerOnOrigin: webgpuActive });
stage = 'playing';
statusLine = webgpuActive
? 'Rendering 150k-particle semantic storm on WebGPU…'
: 'Cinematic flythrough (captions mode).';
lastFrame = performance.now();
director.start();
loop();
}
// BACK-PRESSURED FRAME LOOP. The WebGPU render of 150k particles + compute +
// bloom can exceed a 16ms frame; the old loop fired requestAnimationFrame
// IMMEDIATELY and never awaited render(), so rAF callbacks queued faster than
// the GPU could drain them. Frames piled up → the camera (and the user's
// parallax) visibly lagged behind the input. The fix: AWAIT the render, THEN
// schedule the next frame. This caps the loop to the GPU's real throughput, so
// every frame reflects the latest pointer position with no backlog.
async function loop() {
const now = performance.now();
const dt = Math.max(0, Math.min(0.05, (now - lastFrame) / 1000));
lastFrame = now;
// The camera director is the bulletproof core — it must advance every
// frame regardless of whether the WebGPU render succeeds.
try {
director?.update(dt);
} catch (e) {
console.warn('[cinema] director error:', e);
}
// Compose interactive parallax ON TOP of the director's camera. The user
// nudges yaw/pitch/zoom; we ease toward 0 when idle so the scripted film is
// seamless. sandbox.render() re-clamps distance + lookAt(origin), so the
// user can never break the framing — this is purely additive sugar.
if (!reducedMotion && sandbox && webgpuActive) {
const cam = sandbox.cameraRef;
const idle = performance.now() - lastInputAt > IDLE_MS;
const yawT = idle ? 0 : pointer.x * MAX_YAW;
const pitchT = idle ? 0 : pointer.y * MAX_PITCH;
// Snappier response: faster damping while the user is actively steering
// (lam 9 ≈ ~110ms to converge → feels immediate), gentle glide home when
// idle (lam 1.5) so the handoff back to the film stays cinematic.
const lam = idle ? 1.5 : 9.0;
camOff.yaw = damp(camOff.yaw, yawT, lam, dt);
camOff.pitch = damp(camOff.pitch, pitchT, lam, dt);
if (idle) zoomTarget = damp(zoomTarget, 0, 1.2, dt);
zoomLive = damp(zoomLive, zoomTarget, 8.0, dt);
// Spherical offset around the director's current camera position
// (target = origin).
const dir = cam.position.clone();
const sph = new THREE.Spherical().setFromVector3(dir);
sph.theta += camOff.yaw;
sph.phi = THREE.MathUtils.clamp(sph.phi + camOff.pitch, 0.2, Math.PI - 0.2);
sph.radius *= 1 - zoomLive * 0.35;
dir.setFromSpherical(sph);
cam.position.copy(dir);
}
// Snapshot the sandbox so the async catch can't act on a sandbox that
// close() nulled out while the render promise was in flight.
const sb = sandbox;
if (sb && webgpuActive) {
try {
await sb.render(dt); // ← AWAIT: back-pressure, no frame pile-up
renderFailures = 0;
} catch (e) {
// A render failure must never stall the tour. After a few
// consecutive failures, drop to camera-only (captions still play).
if (++renderFailures >= 3 && sandbox === sb) {
console.warn('[cinema] WebGPU render failing, dropping to camera-only:', e);
webgpuActive = false;
sb.dispose();
sandbox = null;
}
}
}
// Schedule the NEXT frame only after this one's render resolved.
rafId = requestAnimationFrame(loop);
}
// ── ENDLESS DREAM MODE ──────────────────────────────────────────────────
// When the scripted tour ends, instead of freezing on the last figure, the
// storm enters an infinite generative loop: every few seconds it morphs into
// a fresh RANDOM procedural figure (supershape, torus-knot, lissajous, helix,
// quantum foam) and detonates a color blast — each one crazier than the last.
// The render loop() is already running, so we just fire dreamBeats on a timer.
function startDreamMode() {
if (reducedMotion || !sandbox || !webgpuActive) return; // honor reduced-motion
stopDreamMode();
// The endless dream gently flies THROUGH the procedural figures.
if (!reducedMotion) sandbox?.setFlythrough(0.6);
// Fire the first wild figure immediately, then keep going forever.
sandbox?.dreamBeat();
caption = '';
chip = 'Dreaming';
dreamTimer = setInterval(() => {
// Sandbox may have been torn down (close / render-fail fallback).
if (!sandbox || !webgpuActive) {
stopDreamMode();
return;
}
sandbox.dreamBeat();
}, 5500); // a beat every ~5.5s — the blast flares then the figure settles
// into its clean shape before the next detonation.
}
function stopDreamMode() {
if (dreamTimer) {
clearInterval(dreamTimer);
dreamTimer = null;
}
}
function close() {
cancelAnimationFrame(rafId);
stopDreamMode();
if (typeTimer) clearInterval(typeTimer);
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel();
// Reset flythrough + parallax so a Replay never inherits stale offsets.
if (sandbox) sandbox.setFlythrough?.(0);
camOff.yaw = camOff.pitch = 0;
zoomTarget = zoomLive = 0;
director?.stop();
sandbox?.dispose();
sandbox = null;
director = null;
open = false;
stage = 'idle';
webgpuActive = false;
}
// a11y: Escape closes the fullscreen overlay; the close button auto-focuses
// on open so keyboard users land inside the dialog.
let closeBtn = $state<HTMLButtonElement | undefined>(undefined);
function onOverlayKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
close();
} else if (e.key === 'h' || e.key === 'H') {
// Toggle UI chrome for a clean demo capture (hide top bar + captions).
e.preventDefault();
cinemaChrome = !cinemaChrome;
}
}
$effect(() => {
if (open && closeBtn) closeBtn.focus();
});
// Tag <body> while Cinema is open so the graph page can hide its bottom-stats
// pill (which lives behind us in a separate stacking context). Cleaned up when
// Cinema closes or the component unmounts.
$effect(() => {
if (typeof document === 'undefined') return;
document.body.classList.toggle('cinema-open', open);
return () => document.body.classList.remove('cinema-open');
});
// Interactive parallax listeners — only while the overlay is open, motion is
// allowed, and the canvas host exists. Reduced-motion fully disables them
// (no listeners attached at all). Cleanup removes everything on close/unmount.
$effect(() => {
if (!open || reducedMotion || !canvasHost) return;
const host = canvasHost;
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
host.style.touchAction = 'none';
const onPointerMove = (e: PointerEvent) => {
pointer.x = (e.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(e.clientY / window.innerHeight) * 2 + 1;
markInput();
};
const onWheel = (e: WheelEvent) => {
e.preventDefault();
zoomTarget = clamp(zoomTarget + e.deltaY * 0.0008, -1, 1);
markInput();
};
let pinchPrev: number | null = null;
const onTouchMove = (e: TouchEvent) => {
if (e.touches.length === 2) {
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const dist = Math.hypot(dx, dy);
if (pinchPrev !== null) {
zoomTarget = clamp(zoomTarget + (dist - pinchPrev) * 0.002, -1, 1);
}
pinchPrev = dist;
markInput();
}
};
const onTouchEnd = () => {
pinchPrev = null;
};
host.addEventListener('pointermove', onPointerMove, { passive: true });
host.addEventListener('wheel', onWheel, { passive: false });
host.addEventListener('touchmove', onTouchMove, { passive: true });
host.addEventListener('touchend', onTouchEnd);
return () => {
host.removeEventListener('pointermove', onPointerMove);
host.removeEventListener('wheel', onWheel);
host.removeEventListener('touchmove', onTouchMove);
host.removeEventListener('touchend', onTouchEnd);
};
});
// Opt-in on-device narration. Lazy-loads @huggingface/transformers ONLY when
// the user enables "Local AI" and launches — never downloads a model
// unprompted. Runs a small instruction model in-browser on WebGPU to rewrite
// each beat's structured caption into richer prose. Returns null (→ Tier-2
// local captions) ONLY if the package is absent or generation genuinely fails
// — a real implementation with a real fallback, not a placeholder.
type TransformersPipeline = (
input: string,
opts?: Record<string, unknown>
) => Promise<Array<{ generated_text?: string }>>;
function localAiFetcher(): () => Promise<BeatNarration[] | null> {
return async () => {
if (!path) return null;
try {
statusLine = 'Loading on-device model (first run downloads weights)…';
// Computed specifier so TS/Vite don't resolve the optional,
// un-bundled package at build time.
const pkg = '@huggingface/transformers';
const mod = (await import(/* @vite-ignore */ pkg).catch(() => null)) as {
pipeline?: (task: string, model: string, opts?: Record<string, unknown>) => Promise<TransformersPipeline>;
} | null;
if (!mod?.pipeline) return null;
const generate = await mod.pipeline(
'text-generation',
'onnx-community/Qwen2.5-0.5B-Instruct',
{ device: 'webgpu', dtype: 'q4' }
);
// Seed from the deterministic local captions, then enrich each beat.
const base = localCaptions(path);
statusLine = 'Narrating with the on-device model…';
const out: BeatNarration[] = [];
for (const b of base.beats) {
const prompt =
`You are narrating a cinematic tour of an AI's memory graph. ` +
`In one vivid sentence, narrate this beat: "${b.text}"`;
const res = await generate(prompt, { max_new_tokens: 48, temperature: 0.7, do_sample: true });
const text = res?.[0]?.generated_text?.replace(prompt, '').trim();
out.push({ nodeId: b.nodeId, chip: b.chip, text: text && text.length > 4 ? text : b.text });
}
return out;
} catch (e) {
console.warn('[cinema] on-device narration failed, using local captions:', e);
return null;
}
};
}
onDestroy(close);
</script>
<button
class="cinema-launch glass rounded-full px-4 py-2 text-sm text-bright flex items-center gap-2 hover:scale-[1.03] transition"
onclick={launch}
aria-label="Start Memory Cinema — an AI-narrated flythrough of your memory"
>
<span aria-hidden="true">🎬</span> Memory Cinema
</button>
{#if open}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="cinema-overlay"
role="dialog"
aria-modal="true"
aria-label="Memory Cinema"
tabindex="-1"
onkeydown={onOverlayKeydown}
>
<div class="cinema-canvas" bind:this={canvasHost}></div>
<!-- Press H to hide all chrome for a clean demo capture. A tiny restore hint
shows while hidden so it's never a trap. -->
{#if !cinemaChrome}
<button class="cinema-restore" onclick={() => (cinemaChrome = true)} title="Show UI (H)">
press H to show UI
</button>
{/if}
{#if cinemaChrome}
<!-- Top bar: status + close -->
<div class="cinema-top glass-subtle">
<div class="flex items-center gap-2 text-xs text-dim">
<span class="cinema-dot" class:active={stage === 'playing'}></span>
<span>{statusLine}</span>
{#if plan}
<span class="cinema-badge" title="Who directed this film">
{plan.source === 'deterministic' ? 'Auteur (local)' : 'Auteur (AI)'}
</span>
{/if}
{#if narrationSource}
<span class="cinema-badge">{narrationSource === 'backend-llm' ? 'AI narration' : 'Live captions'}</span>
{/if}
{#if webgpuActive}<span class="cinema-badge cinema-badge-gpu">WebGPU</span>{/if}
{#if stage === 'playing'}<span class="cinema-act">Act {act}</span>{/if}
</div>
<div class="flex items-center gap-2">
<label class="cinema-toggle" title="Speak narration aloud">
<input type="checkbox" bind:checked={voiceOn} /> Voice
</label>
<label class="cinema-toggle" title="Use an on-device model for narration (downloads weights on first use)">
<input type="checkbox" bind:checked={localAiOn} /> Local AI
</label>
<button bind:this={closeBtn} class="cinema-close" onclick={close} aria-label="Close Memory Cinema (Esc)"></button>
</div>
</div>
<!-- Pre-roll DIRECTOR'S PLAN card: the AI states its film before rolling. -->
{#if stage === 'planning' && logline}
<div class="cinema-plan-card glass-panel">
<div class="cinema-plan-kicker">Director's plan</div>
<p class="cinema-plan-logline">{logline}</p>
</div>
{/if}
<!-- Bottom: narration captions + director's note + progress -->
<div class="cinema-caption-wrap">
{#if chip}<div class="cinema-chip">{chip}</div>{/if}
<p class="cinema-caption">{caption}</p>
{#if directorNote && stage === 'playing'}
<p class="cinema-note" title="Why the director chose this shot">{directorNote}</p>
{/if}
<div class="cinema-progress" aria-hidden="true">
<div
class="cinema-progress-fill"
style="width:{progress * 100}%; --tension:{tension}"
></div>
</div>
<div class="cinema-beatcount text-dim text-xs">
{#if stage === 'done' && dreamTimer}<span class="cinema-dream">∞ dreaming</span>
{:else if totalBeats > 0}Beat {beatIndex} / {totalBeats}{/if}
{#if stage === 'done'}<button class="cinema-replay" onclick={launch}> Replay</button>{/if}
</div>
</div>
{/if}
</div>
{/if}
<style>
.cinema-overlay {
position: fixed;
inset: 0;
z-index: 200; /* above the graph page's bottom-stats pill + all nav chrome */
background: radial-gradient(circle at 50% 40%, #05050f 0%, #010108 100%);
display: flex;
flex-direction: column;
}
/* While Cinema is open, hide the graph page's bottom-stats pill (it lives in a
separate stacking context on the page behind us and would otherwise bleed
through into a demo capture). Toggled by the .cinema-open class we add to
<body> on mount. */
:global(body.cinema-open .graph-stats-pill) {
display: none;
}
.cinema-canvas {
position: absolute;
inset: 0;
z-index: 0;
}
.cinema-top {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: max(0.75rem, env(safe-area-inset-top)) 1rem 0.75rem;
flex-wrap: wrap;
}
.cinema-badge {
font-size: 0.65rem;
padding: 0.1rem 0.45rem;
border-radius: 999px;
border: 1px solid rgba(129, 140, 248, 0.4);
color: var(--color-synapse-glow);
}
.cinema-badge-gpu {
border-color: rgba(20, 232, 198, 0.5);
color: #14e8c6;
}
.cinema-act {
font-size: 0.6rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-dream-glow);
opacity: 0.85;
}
/* Pre-roll director's plan card — centered, the AI's statement of intent. */
.cinema-plan-card {
position: absolute;
z-index: 3;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 520px;
padding: 1.5rem 1.75rem;
border-radius: 16px;
text-align: center;
animation: cinema-plan-in 0.5s ease both;
}
@keyframes cinema-plan-in {
from { opacity: 0; transform: translate(-50%, -46%); }
to { opacity: 1; transform: translate(-50%, -50%); }
}
.cinema-plan-kicker {
font-size: 0.65rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-synapse-glow);
margin-bottom: 0.5rem;
}
.cinema-plan-logline {
font-size: clamp(1.05rem, 2.2vw, 1.4rem);
line-height: 1.5;
color: var(--color-bright);
margin: 0;
}
.cinema-note {
font-size: 0.78rem;
color: var(--color-synapse-glow);
opacity: 0.85;
margin: 0 0 0.6rem;
font-style: italic;
}
.cinema-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-muted);
}
.cinema-dot.active {
background: #14e8c6;
box-shadow: 0 0 10px #14e8c6;
}
.cinema-toggle {
font-size: 0.7rem;
color: var(--color-dim);
display: flex;
align-items: center;
gap: 0.3rem;
cursor: pointer;
}
.cinema-close {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.15);
color: var(--color-text);
border-radius: 8px;
width: 32px;
height: 32px;
cursor: pointer;
}
.cinema-caption-wrap {
position: relative;
z-index: 2;
margin-top: auto;
padding: 1rem 1.25rem max(1.25rem, env(safe-area-inset-bottom));
max-width: 720px;
}
.cinema-chip {
display: inline-block;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-dream-glow);
margin-bottom: 0.35rem;
}
.cinema-caption {
font-size: clamp(1.05rem, 2.4vw, 1.6rem);
line-height: 1.45;
color: var(--color-bright);
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.9);
min-height: 2.6em;
margin: 0 0 0.75rem;
}
.cinema-progress {
height: 3px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
overflow: hidden;
}
.cinema-progress-fill {
height: 100%;
/* Tint shifts toward crimson as the shot's tension rises (--tension 0..1). */
background: linear-gradient(
90deg,
var(--color-synapse),
color-mix(in oklch, var(--color-dream), #ff2d55 calc(var(--tension, 0) * 100%))
);
transition: width 0.2s linear, background 0.4s ease;
}
.cinema-beatcount {
margin-top: 0.4rem;
display: flex;
gap: 0.75rem;
align-items: center;
}
.cinema-replay {
background: transparent;
border: 1px solid rgba(129, 140, 248, 0.4);
color: var(--color-synapse-glow);
border-radius: 999px;
padding: 0.15rem 0.7rem;
cursor: pointer;
font-size: 0.75rem;
}
.cinema-dream {
color: var(--color-dream-glow);
letter-spacing: 0.08em;
animation: cinema-dream-pulse 3s ease-in-out infinite;
}
/* Tiny restore hint shown while chrome is hidden (demo-capture mode). */
.cinema-restore {
position: absolute;
bottom: 0.6rem;
right: 0.8rem;
z-index: 95;
background: rgba(10, 10, 26, 0.5);
border: 1px solid rgba(129, 140, 248, 0.25);
color: var(--color-muted);
border-radius: 999px;
padding: 0.2rem 0.7rem;
font-size: 0.7rem;
letter-spacing: 0.04em;
cursor: pointer;
opacity: 0.4;
transition: opacity 0.2s ease;
}
.cinema-restore:hover {
opacity: 1;
}
@keyframes cinema-dream-pulse {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.cinema-progress-fill {
transition: none;
}
}
</style>

View file

@ -0,0 +1,69 @@
<script lang="ts">
import Icon, { type IconName } from './Icon.svelte';
// ═══════════════════════════════════════════════════════════════════
// PAGE HEADER — the shared "alive" page title used on every route.
// ───────────────────────────────────────────────────────────────────
// A drawn-on unique route icon in a glowing tile, an aurora-gradient
// title, an optional subtitle, and an optional right-aligned slot for
// live counts / actions. Replaces the flat `<h1>` each page had, giving
// the whole app one premium, consistent, animated masthead.
// ═══════════════════════════════════════════════════════════════════
interface Props {
icon: IconName;
title: string;
subtitle?: string;
/** Tailwind color token name for the icon tile accent (e.g. 'synapse'). */
accent?: string;
children?: import('svelte').Snippet;
}
let { icon, title, subtitle, accent = 'synapse', children }: Props = $props();
</script>
<header class="flex items-start justify-between gap-4 mb-6 enter">
<div class="flex items-center gap-3.5 min-w-0">
<div
class="header-tile relative flex items-center justify-center w-11 h-11 rounded-xl shrink-0
bg-{accent}/12 border border-{accent}/25 text-{accent}-glow"
>
<Icon name={icon} size={22} draw />
</div>
<div class="min-w-0">
<h1 class="text-2xl font-bold text-aurora leading-tight text-balance">{title}</h1>
{#if subtitle}
<p class="text-sm text-dim mt-0.5 text-pretty">{subtitle}</p>
{/if}
</div>
</div>
{#if children}
<div class="flex items-center gap-2 shrink-0 flex-wrap justify-end">
{@render children()}
</div>
{/if}
</header>
<style>
/* Soft outward glow that gently pulses, so the masthead icon reads as
"live" the moment the page lands. */
.header-tile::after {
content: '';
position: absolute;
inset: -1px;
border-radius: inherit;
box-shadow: 0 0 18px -2px currentColor;
opacity: 0.35;
pointer-events: none;
}
@media not (prefers-reduced-motion: reduce) {
.header-tile::after {
animation: tile-glow 4s ease-in-out infinite;
}
@keyframes tile-glow {
0%, 100% { opacity: 0.22; }
50% { opacity: 0.5; }
}
}
.text-balance { text-wrap: balance; }
.text-pretty { text-wrap: pretty; }
</style>

View file

@ -0,0 +1,204 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { planShotsDeterministic, resolveShots, SHOT_DEFAULTS, type DirectorPlan } from '../auteur';
import { planCinemaPath } from '../pathfinder';
import { computeSignals } from '../topology';
import { makeNode, makeEdge, resetNodeCounter } from '../../__tests__/helpers';
describe('auteur — carry-forward shot resolution', () => {
beforeEach(() => resetNodeCounter());
function smallPath() {
const a = makeNode({ id: 'a' });
const b = makeNode({ id: 'b' });
const c = makeNode({ id: 'c' });
const edges = [makeEdge('a', 'b', { weight: 0.8 }), makeEdge('b', 'c', { weight: 0.6 })];
return { path: planCinemaPath([a, b, c], edges, 'a'), nodes: [a, b, c], edges };
}
it('fills EVERY axis from a one-field shot, defaulting to today constants', () => {
const { path } = smallPath();
const plan: DirectorPlan = {
source: 'backend-llm',
logline: 'x',
arc: 'flat',
shots: [{ nodeId: 'a', move: 'orbit', why: 'test' }],
};
const resolved = resolveShots(plan, path);
expect(resolved).toHaveLength(path.beats.length);
// The specified field is honored…
expect(resolved[0].move).toBe('orbit');
// …and every other axis is a real default, never undefined.
expect(resolved[0].standoff).toBe(SHOT_DEFAULTS.standoff);
expect(resolved[0].flightSeconds).toBe(SHOT_DEFAULTS.flightSeconds);
expect(resolved[0].angle).toBe('eye');
for (const s of resolved) {
for (const k of Object.keys(SHOT_DEFAULTS) as (keyof typeof SHOT_DEFAULTS)[]) {
expect(s[k]).toBeDefined();
}
expect(typeof s.why).toBe('string');
expect(s.why.length).toBeGreaterThan(0);
}
});
it('carries non-cut axes forward to subsequent beats', () => {
const { path } = smallPath();
const plan: DirectorPlan = {
source: 'backend-llm',
logline: 'x',
arc: 'flat',
// Only the FIRST beat sets standoff; later beats should inherit it.
shots: [{ nodeId: path.beats[0].nodeId, standoff: 41, why: 'set' }],
};
const resolved = resolveShots(plan, path);
expect(resolved[0].standoff).toBe(41);
expect(resolved[resolved.length - 1].standoff).toBe(41); // carried forward
});
it('cut never carries forward — defaults to fly each beat', () => {
const { path } = smallPath();
const plan: DirectorPlan = {
source: 'backend-llm',
logline: 'x',
arc: 'flat',
shots: [{ nodeId: path.beats[0].nodeId, cut: 'hard_cut', why: 'cut' }],
};
const resolved = resolveShots(plan, path);
expect(resolved[0].cut).toBe('hard_cut');
if (resolved.length > 1) expect(resolved[1].cut).toBe('fly');
});
it('back-fills garbage / out-of-range LLM fields from defaults', () => {
const { path } = smallPath();
const plan = {
source: 'backend-llm',
logline: 'x',
arc: 'flat',
shots: [
{
nodeId: path.beats[0].nodeId,
move: 'teleport', // invalid enum
standoff: 9999, // out of range
dwellSeconds: -5, // out of range
why: '',
},
],
} as unknown as DirectorPlan;
const resolved = resolveShots(plan, path);
expect(resolved[0].move).toBe(SHOT_DEFAULTS.move); // invalid → default
expect(resolved[0].standoff).toBeLessThanOrEqual(90); // clamped
expect(resolved[0].dwellSeconds).toBeGreaterThanOrEqual(0.6); // clamped
expect(resolved[0].why.length).toBeGreaterThan(0); // empty why → fallback
});
it('a null plan still yields one default shot per beat', () => {
const { path } = smallPath();
const resolved = resolveShots(null, path);
expect(resolved).toHaveLength(path.beats.length);
expect(resolved[0].move).toBe(SHOT_DEFAULTS.move);
});
});
describe('auteur — deterministic director', () => {
beforeEach(() => resetNodeCounter());
it('produces a valid plan: one grounded shot per beat, every why non-empty, every nodeId real', () => {
const nodes = Array.from({ length: 8 }, (_, i) => makeNode({ id: `n${i}` }));
const edges = [
makeEdge('n0', 'n1', { weight: 0.9 }),
makeEdge('n1', 'n2', { weight: 0.2, type: 'contradiction' }),
makeEdge('n0', 'n3', { weight: 0.5 }),
makeEdge('n3', 'n4', { weight: 0.7 }),
];
const path = planCinemaPath(nodes, edges, 'n0');
const signals = computeSignals(nodes, edges);
const plan = planShotsDeterministic(path, signals);
const realIds = new Set(nodes.map((n) => n.id));
expect(plan.shots).toHaveLength(path.beats.length);
for (const s of plan.shots) {
expect(realIds.has(s.nodeId)).toBe(true);
expect(s.why && s.why.length).toBeGreaterThan(0);
}
expect(plan.source).toBe('deterministic');
expect(plan.logline.length).toBeGreaterThan(0);
});
it('directs a contradiction beat as a Dutch hard-cut crimson collision', () => {
const a = makeNode({ id: 'a' });
const normal = makeNode({ id: 'normal' });
const conflict = makeNode({ id: 'conflict' });
const edges = [
makeEdge('a', 'normal', { weight: 0.95 }),
makeEdge('a', 'conflict', { weight: 0.2, type: 'contradiction' }),
];
const path = planCinemaPath([a, normal, conflict], edges, 'a');
const signals = computeSignals([a, normal, conflict], edges);
const plan = planShotsDeterministic(path, signals);
const contradictionShot = plan.shots.find((_, i) => path.beats[i].kind === 'contradiction');
expect(contradictionShot).toBeDefined();
expect(contradictionShot!.stormMode).toBe('contradiction');
expect(contradictionShot!.cut).toBe('hard_cut');
expect(contradictionShot!.dutch).toBeGreaterThan(0);
expect(contradictionShot!.scoreCue).toBe('minor_drop');
});
it('ends on a crane pull-back with a major resolve', () => {
const nodes = Array.from({ length: 5 }, (_, i) => makeNode({ id: `m${i}` }));
const edges = nodes.slice(1).map((n, i) => makeEdge(`m${i}`, n.id, { weight: 0.6 }));
const path = planCinemaPath(nodes, edges, 'm0');
const signals = computeSignals(nodes, edges);
const plan = planShotsDeterministic(path, signals);
const last = plan.shots[plan.shots.length - 1];
expect(last.move).toBe('crane');
expect(last.scoreCue).toBe('major_resolve');
});
it('is deterministic — same inputs yield the same plan', () => {
const nodes = Array.from({ length: 6 }, (_, i) => makeNode({ id: `d${i}` }));
const edges = [makeEdge('d0', 'd1', { weight: 0.8 }), makeEdge('d1', 'd2', { weight: 0.5 })];
const path = planCinemaPath(nodes, edges, 'd0');
const sig = computeSignals(nodes, edges);
const p1 = planShotsDeterministic(path, sig);
const p2 = planShotsDeterministic(path, sig);
expect(p1.shots.map((s) => s.move)).toEqual(p2.shots.map((s) => s.move));
expect(p1.logline).toBe(p2.logline);
});
});
describe('topology — graph signals', () => {
beforeEach(() => resetNodeCounter());
it('computes betweenness, clusters, and peak keystone on a real shape', () => {
// Two clusters bridged by 'hub' → hub has the highest betweenness.
const hub = makeNode({ id: 'hub' });
const l1 = makeNode({ id: 'l1' });
const l2 = makeNode({ id: 'l2' });
const r1 = makeNode({ id: 'r1' });
const r2 = makeNode({ id: 'r2' });
const edges = [
makeEdge('l1', 'l2'),
makeEdge('l2', 'hub'),
makeEdge('hub', 'r1'),
makeEdge('r1', 'r2'),
];
const sig = computeSignals([hub, l1, l2, r1, r2], edges);
expect(sig.peakBetweennessId).toBe('hub');
expect(sig.nodes.get('hub')!.betweenness).toBeGreaterThan(sig.nodes.get('l1')!.betweenness);
expect(sig.clusterCount).toBe(1); // all connected through hub
// All signals are finite and in range.
for (const s of sig.nodes.values()) {
expect(s.betweenness).toBeGreaterThanOrEqual(0);
expect(s.betweenness).toBeLessThanOrEqual(1);
expect(Number.isFinite(s.recencyRank)).toBe(true);
}
});
it('flags contradiction edges and computes surprise in range', () => {
const a = makeNode({ id: 'a' });
const b = makeNode({ id: 'b' });
const edges = [makeEdge('a', 'b', { weight: 0.1, type: 'contradiction' })];
const sig = computeSignals([a, b], edges);
expect(sig.edges[0].isContradiction).toBe(true);
expect(sig.edges[0].surprise).toBeGreaterThanOrEqual(0);
expect(sig.edges[0].surprise).toBeLessThanOrEqual(1);
});
});

View file

@ -0,0 +1,91 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { planCinemaPath } from '../pathfinder';
import { makeNode, makeEdge, resetNodeCounter } from '../../__tests__/helpers';
describe('planCinemaPath', () => {
beforeEach(() => resetNodeCounter());
it('returns an empty path for no nodes', () => {
const path = planCinemaPath([], [], 'missing');
expect(path.beats).toEqual([]);
expect(path.flowEdges).toEqual([]);
});
it('starts at the requested center when it exists', () => {
const a = makeNode({ id: 'a' });
const b = makeNode({ id: 'b' });
const path = planCinemaPath([a, b], [makeEdge('a', 'b')], 'a');
expect(path.beats[0].nodeId).toBe('a');
expect(path.beats[0].kind).toBe('origin');
expect(path.beats[0].viaEdge).toBeNull();
});
it('falls back to the most-connected node when center is missing', () => {
const hub = makeNode({ id: 'hub' });
const x = makeNode({ id: 'x' });
const y = makeNode({ id: 'y' });
const path = planCinemaPath(
[x, hub, y],
[makeEdge('hub', 'x'), makeEdge('hub', 'y')],
'does-not-exist'
);
expect(path.beats[0].nodeId).toBe('hub');
});
it('visits the strongest-weighted connection first', () => {
const a = makeNode({ id: 'a' });
const weak = makeNode({ id: 'weak' });
const strong = makeNode({ id: 'strong' });
const path = planCinemaPath(
[a, weak, strong],
[makeEdge('a', 'weak', { weight: 0.1 }), makeEdge('a', 'strong', { weight: 0.9 })],
'a'
);
expect(path.beats[1].nodeId).toBe('strong');
expect(path.beats[1].kind).toBe('connection');
});
it('detours through a contradiction edge when reachable', () => {
const a = makeNode({ id: 'a' });
const normal = makeNode({ id: 'normal' });
const conflict = makeNode({ id: 'conflict' });
const path = planCinemaPath(
[a, normal, conflict],
[
makeEdge('a', 'normal', { weight: 0.95, type: 'semantic' }),
makeEdge('a', 'conflict', { weight: 0.2, type: 'contradiction' }),
],
'a'
);
const kinds = path.beats.map((b) => b.kind);
expect(kinds).toContain('contradiction');
// The contradiction beat carries max intensity.
const c = path.beats.find((b) => b.kind === 'contradiction');
expect(c?.intensity).toBe(1);
});
it('never exceeds maxBeats and never repeats a node', () => {
const nodes = Array.from({ length: 20 }, (_, i) => makeNode({ id: `n${i}` }));
const edges = nodes.slice(1).map((n) => makeEdge('n0', n.id, { weight: Math.random() }));
const path = planCinemaPath(nodes, edges, 'n0', 5);
expect(path.beats.length).toBeLessThanOrEqual(5);
const ids = path.beats.map((b) => b.nodeId);
expect(new Set(ids).size).toBe(ids.length);
});
it('is deterministic — same inputs yield the same path', () => {
const nodes = [makeNode({ id: 'a' }), makeNode({ id: 'b' }), makeNode({ id: 'c' })];
const edges = [makeEdge('a', 'b', { weight: 0.8 }), makeEdge('b', 'c', { weight: 0.6 })];
const p1 = planCinemaPath(nodes, edges, 'a');
const p2 = planCinemaPath(nodes, edges, 'a');
expect(p1.beats.map((b) => b.nodeId)).toEqual(p2.beats.map((b) => b.nodeId));
});
it('records flowEdges for each traversed connection', () => {
const a = makeNode({ id: 'a' });
const b = makeNode({ id: 'b' });
const path = planCinemaPath([a, b], [makeEdge('a', 'b', { weight: 0.7 })], 'a');
expect(path.flowEdges.length).toBeGreaterThanOrEqual(1);
expect(path.flowEdges[0].source === 'a' || path.flowEdges[0].target === 'a').toBe(true);
});
});

View file

@ -0,0 +1,223 @@
// The Auteur — the director's brain + the typed shot-plan contract.
//
// The LLM (Tier 1) or the deterministic rule table (Tier 2) produces a
// DirectorPlan: a sequence of cinematographic Shots, one per CinemaBeat, each
// grounded in a real node and justified by a real graph metric. The camera
// runtime (director.ts) executes it. Carry-forward semantics mean a sparse or
// half-hallucinated plan ALWAYS resolves to a coherent film — the same
// robustness pattern as narrator.resolveNarration.
import type { CinemaPath, CinemaBeat } from './pathfinder';
import type { GraphSignals } from './topology';
// ── Camera grammar (string unions keep LLM output validatable) ───────────────
export type Move = 'push_in' | 'pull_back' | 'orbit' | 'crane' | 'whip_pan' | 'rack_focus' | 'hold';
export type Angle = 'eye' | 'low' | 'high'; // low = look up (power); high = look down (decay)
export type Cut = 'fly' | 'hard_cut' | 'match_cut';
export type StormMode = 'anchor' | 'connection' | 'contradiction' | 'surprise';
export type CaptionTone = 'curious' | 'tense' | 'resolved' | 'awe' | 'neutral';
export type ScoreCue = 'motif' | 'minor_drop' | 'major_resolve' | 'silence';
export type Act = 'I' | 'II' | 'III';
export type EmotionalArc = 'man_in_hole' | 'rags_to_riches' | 'icarus' | 'cinderella' | 'oedipus' | 'flat';
export type DirectorSource = 'backend-llm' | 'on-device' | 'deterministic';
/** A directed shot. Only axes that CHANGE need be set the rest carry forward
* from the previous resolved shot (ultimate default = today's camera constants). */
export interface Shot {
nodeId: string; // MUST cite a real node (alignment key + grounding constraint)
move?: Move;
angle?: Angle;
dutch?: number; // camera roll, radians, 0..~0.5
standoff?: number; // world units
flightSeconds?: number;
dwellSeconds?: number;
halflife?: number; // spring smoothing; 0 = jump-cut
cut?: Cut;
stormMode?: StormMode;
intensity?: number; // 0..1 → scales the ignition spike
tension?: number; // 0..1 master scalar
act?: Act;
tone?: CaptionTone;
scoreCue?: ScoreCue;
why: string; // REQUIRED: cites the real metric driving this shot
viaEdgeKey?: string; // `${source}->${target}` for two-node framing
}
export interface DirectorPlan {
source: DirectorSource;
logline: string;
arc: EmotionalArc;
shots: Shot[];
}
/** Every axis filled after carry-forward — what the director reads each beat. */
export type ResolvedShot = Required<Omit<Shot, 'viaEdgeKey'>> & { viaEdgeKey?: string };
// Ultimate defaults — today's hardcoded camera constants, so a plan-less or
// fully-sparse run is byte-identical to the pre-Auteur camera.
export const SHOT_DEFAULTS: Omit<ResolvedShot, 'nodeId' | 'why'> = {
move: 'hold',
angle: 'eye',
dutch: 0,
standoff: 26,
flightSeconds: 2.4,
dwellSeconds: 3.2,
halflife: 0.35,
cut: 'fly',
stormMode: 'connection',
intensity: 0.7,
tension: 0.3,
act: 'I',
tone: 'neutral',
scoreCue: 'motif',
};
const MOVES: ReadonlySet<Move> = new Set(['push_in', 'pull_back', 'orbit', 'crane', 'whip_pan', 'rack_focus', 'hold']);
const ANGLES: ReadonlySet<Angle> = new Set(['eye', 'low', 'high']);
const CUTS: ReadonlySet<Cut> = new Set(['fly', 'hard_cut', 'match_cut']);
const STORM_MODES: ReadonlySet<StormMode> = new Set(['anchor', 'connection', 'contradiction', 'surprise']);
const TONES: ReadonlySet<CaptionTone> = new Set(['curious', 'tense', 'resolved', 'awe', 'neutral']);
const SCORE_CUES: ReadonlySet<ScoreCue> = new Set(['motif', 'minor_drop', 'major_resolve', 'silence']);
const ACTS: ReadonlySet<Act> = new Set(['I', 'II', 'III']);
function num(v: unknown, lo: number, hi: number, fallback: number): number {
const n = typeof v === 'number' && Number.isFinite(v) ? v : NaN;
if (Number.isNaN(n)) return fallback;
return Math.max(lo, Math.min(hi, n));
}
function pick<T>(v: unknown, set: ReadonlySet<T>, fallback: T): T {
return typeof v === 'string' && set.has(v as T) ? (v as T) : fallback;
}
/**
* Resolve a DirectorPlan into one fully-specified ResolvedShot per beat.
* Aligns by nodeId; every unspecified/garbage axis is back-filled by carry-forward
* (previous shot SHOT_DEFAULTS). A shot can NEVER be blank or invalid.
*/
export function resolveShots(plan: DirectorPlan | null, path: CinemaPath): ResolvedShot[] {
const byNode = new Map<string, Shot>();
for (const s of plan?.shots ?? []) {
if (s && typeof s.nodeId === 'string') byNode.set(s.nodeId, s);
}
const resolved: ResolvedShot[] = [];
let prev: ResolvedShot | null = null;
for (const beat of path.beats) {
const raw = byNode.get(beat.nodeId);
const base = prev ?? { ...SHOT_DEFAULTS, nodeId: beat.nodeId, why: '' };
const shot: ResolvedShot = {
nodeId: beat.nodeId,
move: pick(raw?.move, MOVES, base.move),
angle: pick(raw?.angle, ANGLES, base.angle),
dutch: num(raw?.dutch, 0, 0.6, base.dutch),
standoff: num(raw?.standoff, 8, 90, base.standoff),
flightSeconds: num(raw?.flightSeconds, 0.4, 6, base.flightSeconds),
dwellSeconds: num(raw?.dwellSeconds, 0.6, 8, base.dwellSeconds),
halflife: num(raw?.halflife, 0, 1.5, base.halflife),
cut: pick(raw?.cut, CUTS, 'fly'), // cut never carries forward — default per beat
stormMode: pick(raw?.stormMode, STORM_MODES, base.stormMode),
intensity: num(raw?.intensity, 0, 1, base.intensity),
tension: num(raw?.tension, 0, 1, base.tension),
act: pick(raw?.act, ACTS, base.act),
tone: pick(raw?.tone, TONES, base.tone),
scoreCue: pick(raw?.scoreCue, SCORE_CUES, 'motif'),
why: typeof raw?.why === 'string' && raw.why.trim() ? raw.why : base.why || 'establishing shot',
viaEdgeKey: typeof raw?.viaEdgeKey === 'string' ? raw.viaEdgeKey : undefined,
};
resolved.push(shot);
prev = shot;
}
return resolved;
}
// ── The deterministic auteur (Tier 2) ────────────────────────────────────────
// The graph-metric → shot-grammar rule table. This SAME table is handed to the
// LLM as its system prompt (see directorSystemPrompt), so Tier-1 output is
// directly comparable to and back-fillable against this baseline.
function actFor(progress: number): Act {
return progress < 0.34 ? 'I' : progress < 0.72 ? 'II' : 'III';
}
/**
* Produce a cinematic DirectorPlan from pure graph signals no LLM. This alone
* ships the hero film: every shot is grounded and justified by a real metric.
*/
export function planShotsDeterministic(path: CinemaPath, signals: GraphSignals): DirectorPlan {
const n = path.beats.length;
const shots: Shot[] = path.beats.map((beat, i) => {
const progress = n > 1 ? i / (n - 1) : 0;
const act = actFor(progress);
const sig = signals.nodes.get(beat.nodeId);
const isPeak = beat.nodeId === signals.peakBetweennessId;
const isFinale = i === n - 1;
const isOrigin = i === 0;
// Default shot for a plain connection beat.
let shot: Shot = {
nodeId: beat.nodeId,
move: 'push_in',
angle: 'eye',
cut: 'fly',
stormMode: 'connection',
tone: 'curious',
scoreCue: 'motif',
act,
intensity: 0.6,
tension: 0.3,
why: 'a connected memory',
};
if (isOrigin) {
shot = { ...shot, move: 'push_in', tone: 'curious', tension: 0.25, stormMode: 'anchor', why: 'opening on the focal memory' };
}
// High-betweenness keystone → reverent low-angle slow orbit.
if (isPeak || (sig && sig.betweenness > 0.6)) {
shot = { ...shot, move: 'orbit', angle: 'low', stormMode: 'anchor', intensity: 0.75, tension: 0.45, tone: 'awe', why: 'low-angle orbit — the most load-bearing memory in the graph' };
}
// Contradiction → Dutch push-in, hard cut, crimson chaos, minor drop.
if (beat.kind === 'contradiction') {
shot = { ...shot, move: 'push_in', angle: 'eye', dutch: 0.28, cut: 'hard_cut', stormMode: 'contradiction', intensity: 1, tension: 0.95, tone: 'tense', scoreCue: 'minor_drop', viaEdgeKey: beat.viaEdge ? `${beat.viaEdge.source}->${beat.viaEdge.target}` : undefined, why: 'two memories in tension — a Dutch two-shot collision' };
}
// Surprise edge → gold/violet convergence, rising awe.
if (beat.kind === 'surprise') {
shot = { ...shot, move: 'orbit', stormMode: 'surprise', intensity: 0.85, tension: 0.6, tone: 'awe', scoreCue: 'motif', why: 'a surprising, distant-but-plausible connection' };
}
// Fading memory → drifting high angle.
if (sig && (sig.retention < 0.35 || sig.suppression > 0.5)) {
shot = { ...shot, angle: 'high', move: 'pull_back', tone: 'neutral', intensity: 0.4, why: 'a fading memory — high-angle drift' };
}
// Recent → the "now" beat.
if (beat.kind === 'recent') {
shot = { ...shot, move: 'push_in', tone: 'resolved', tension: 0.4, why: 'where the memory is now' };
}
// Finale → crane pull-back, major resolve.
if (isFinale) {
shot = { ...shot, move: 'crane', cut: 'fly', stormMode: 'anchor', tone: 'awe', tension: 0.5, scoreCue: 'major_resolve', why: 'crane pull-back over the whole cluster — resolution' };
}
return shot;
});
const arc: EmotionalArc = path.beats.some((b) => b.kind === 'contradiction') ? 'man_in_hole' : 'rags_to_riches';
const originLabel = path.beats[0]?.node.label ?? 'a memory';
const logline = `A short film about ${originLabel}${n} shots through the graph${arc === 'man_in_hole' ? ', through a contradiction and out the other side' : ''}.`;
return { source: 'deterministic', logline, arc, shots };
}
/** The rule table as an LLM system prompt keeps Tier-1 output comparable to
* the Tier-2 baseline (and thus back-fillable by resolveShots). */
export function directorSystemPrompt(): string {
return [
'You are a film director shooting a short documentary about an AI\'s own memory graph.',
'Output a DirectorPlan: a logline, an emotional arc, and one shot per beat.',
'Each shot MUST cite a real nodeId and a real "why" referencing a graph metric.',
'Grammar → meaning:',
'- high betweenness (load-bearing memory) → low-angle slow orbit, reverent',
'- contradiction edge → Dutch angle + push_in + hard_cut + crimson storm + minor_drop score',
'- surprising distant link → gold/violet orbit→stream convergence + awe',
'- merge/supersede → match_cut at identical standoff+angle (same idea)',
'- low retention / high suppression → high-angle drift (fading)',
'- finale → crane pull_back + major_resolve',
'Build a real emotional arc across acts I→II→III. Only specify axes that change.',
].join('\n');
}

View file

@ -0,0 +1,285 @@
// Memory Cinema — the camera director.
//
// Drives a smooth, cinematic camera flight through a planned CinemaPath. Pure
// choreography: it mutates a THREE.PerspectiveCamera + an OrbitControls-like
// target each frame and emits beat-arrival callbacks the narrator + sandbox
// hook into. It knows nothing about which renderer (WebGL/WebGPU) is on screen,
// so it works identically for the legacy graph and the WebGPU sandbox.
//
// Respects prefers-reduced-motion: when reduced, it JUMP-CUTS between beats
// (instant position, dwell, advance) instead of flying — captions still fire.
import * as THREE from 'three';
import type { CinemaPath, CinemaBeat } from './pathfinder';
import type { ResolvedShot } from './auteur';
export interface DirectorCallbacks {
/** Fired once when the camera arrives at (or cuts to) a beat. The resolved
* shot for the beat is passed so consumers can drive storm/score/captions. */
onBeat?: (beat: CinemaBeat, index: number, shot: ResolvedShot | null) => void;
/** Fired when the whole tour finishes. */
onComplete?: () => void;
/** Fired every frame with overall progress 0..1 (for a scrubber/progress bar). */
onProgress?: (t: number) => void;
}
export interface DirectorOptions {
/** Seconds of camera flight between consecutive beats. */
flightSeconds?: number;
/** Seconds the camera dwells on each beat before advancing. */
dwellSeconds?: number;
/** Stand-off distance from the focused node, in world units. */
standoff?: number;
/** Instant cuts instead of flights (prefers-reduced-motion). */
reducedMotion?: boolean;
/** Optional per-beat director's plan (one ResolvedShot per beat, aligned by
* index). When ABSENT the camera behaves byte-identically to the pre-Auteur
* director every value falls back to the constants above. When present,
* each shot's move/angle/dutch/standoff/flight/dwell/cut directs that beat. */
shots?: ResolvedShot[];
/** When true, the camera frames the WORLD ORIGIN every shot (the WebGPU storm
* is pinned there) instead of flying out to scattered node positions so the
* subject is ALWAYS centered and can never fly off-screen. Camera variety
* comes purely from angle/standoff/orbit. Used by the WebGPU sandbox path. */
centerOnOrigin?: boolean;
}
type Phase = 'idle' | 'flying' | 'dwelling' | 'done';
const _tmpDir = new THREE.Vector3();
const _tmpUp = new THREE.Vector3(0, 1, 0);
const _origin = new THREE.Vector3(0, 0, 0);
export class CinemaDirector {
private camera: THREE.PerspectiveCamera;
private target: THREE.Vector3;
private positions: Map<string, THREE.Vector3>;
private path: CinemaPath;
private cb: DirectorCallbacks;
private opts: Required<DirectorOptions>;
private phase: Phase = 'idle';
private beatIndex = 0;
private phaseElapsed = 0;
// Flight interpolation endpoints.
private fromPos = new THREE.Vector3();
private toPos = new THREE.Vector3();
private fromTarget = new THREE.Vector3();
private toTarget = new THREE.Vector3();
constructor(
camera: THREE.PerspectiveCamera,
target: THREE.Vector3,
positions: Map<string, THREE.Vector3>,
path: CinemaPath,
cb: DirectorCallbacks = {},
opts: DirectorOptions = {}
) {
this.camera = camera;
this.target = target;
this.positions = positions;
this.path = path;
this.cb = cb;
this.opts = {
flightSeconds: opts.flightSeconds ?? 2.4,
dwellSeconds: opts.dwellSeconds ?? 3.2,
standoff: opts.standoff ?? 26,
reducedMotion: opts.reducedMotion ?? false,
shots: opts.shots ?? [],
centerOnOrigin: opts.centerOnOrigin ?? false,
};
}
/** The resolved shot directing a beat, or null when no plan was supplied
* ( the camera uses the constant defaults = pre-Auteur behavior). */
private shotAt(index: number): ResolvedShot | null {
return this.opts.shots[index] ?? null;
}
/** Per-beat flight duration: the shot's value, else the global default. A
* hard/match cut has zero flight (handled in beginFlightTo). */
private flightSecondsAt(index: number): number {
return this.shotAt(index)?.flightSeconds ?? this.opts.flightSeconds;
}
private dwellSecondsAt(index: number): number {
return this.shotAt(index)?.dwellSeconds ?? this.opts.dwellSeconds;
}
get totalBeats(): number {
return this.path.beats.length;
}
get isRunning(): boolean {
return this.phase !== 'idle' && this.phase !== 'done';
}
/** Begin the tour from the first beat. */
start(): void {
if (this.path.beats.length === 0) {
this.phase = 'done';
this.cb.onComplete?.();
return;
}
this.beatIndex = 0;
this.beginFlightTo(0);
}
stop(): void {
this.phase = 'done';
}
/** The focal point a beat frames: the world ORIGIN in centered mode (storm is
* pinned there), else the node's laid-out position. */
private focalPoint(beat: CinemaBeat): THREE.Vector3 | null {
if (this.opts.centerOnOrigin) return _origin;
return this.positions.get(beat.nodeId) ?? null;
}
/** Compute the camera stand-off position for a beat's focal point, directed by
* its shot (move / angle / standoff). With no shot, reproduces the original
* framing exactly: standoff = opts.standoff, +0.35 up-bias (filmic tilt). */
private framePosition(beat: CinemaBeat, index: number, out: THREE.Vector3): THREE.Vector3 {
const nodePos = this.focalPoint(beat);
if (!nodePos) {
// Node has no resolved position yet — keep current framing.
return out.copy(this.camera.position);
}
const shot = this.shotAt(index);
_tmpDir.copy(this.camera.position).sub(nodePos);
if (_tmpDir.lengthSq() < 1e-4) _tmpDir.set(0, 0.4, 1);
_tmpDir.normalize();
// Vertical bias = the camera angle. Default +0.35 (slightly above, the
// original filmic tilt). low = look UP at the node (power), high = look
// DOWN (decay/fading).
let upBias = 0.35;
if (shot) {
if (shot.angle === 'low') upBias = -0.45;
else if (shot.angle === 'high') upBias = 0.7;
}
_tmpDir.addScaledVector(_tmpUp, upBias).normalize();
// Stand-off = how close: push_in tightens, pull_back/crane widen.
let standoff = shot?.standoff ?? this.opts.standoff;
if (shot) {
if (shot.move === 'push_in') standoff *= 0.7;
else if (shot.move === 'pull_back') standoff *= 1.5;
else if (shot.move === 'crane') standoff *= 1.8;
}
// In centered (WebGPU storm) mode the subject is pinned to the origin and
// the sandbox clamps the camera to a far band. Keep the directed standoff
// INSIDE that band so the camera never fights the clamp (which read as an
// off-center jump) — variety here comes from angle + orbit, not distance.
if (this.opts.centerOnOrigin) standoff = Math.max(31, Math.min(43, standoff));
return out.copy(nodePos).addScaledVector(_tmpDir, standoff);
}
private beginFlightTo(index: number): void {
const beat = this.path.beats[index];
const nodePos = this.focalPoint(beat);
const shot = this.shotAt(index);
this.fromPos.copy(this.camera.position);
this.fromTarget.copy(this.target);
this.framePosition(beat, index, this.toPos);
this.toTarget.copy(nodePos ?? this.target);
this.phaseElapsed = 0;
// A directed hard/match cut snaps instantly (like reduced-motion), so the
// editorial "cut" reads as an edit, not a fly. reduced-motion forces this
// for every beat regardless of shot.
const snap = this.opts.reducedMotion || shot?.cut === 'hard_cut' || shot?.cut === 'match_cut';
if (snap) {
this.camera.position.copy(this.toPos);
this.target.copy(this.toTarget);
this.phase = 'dwelling';
this.cb.onBeat?.(beat, index, shot);
} else {
this.phase = 'flying';
}
}
/** Advance the choreography. Call once per animation frame with delta seconds. */
update(deltaSeconds: number): void {
if (this.phase === 'idle' || this.phase === 'done') return;
// Clamp dt so a tab-switch stall doesn't teleport the camera.
const dt = Math.max(0, Math.min(deltaSeconds, 0.05));
this.phaseElapsed += dt;
const flightSecs = this.flightSecondsAt(this.beatIndex);
const dwellSecs = this.dwellSecondsAt(this.beatIndex);
if (this.phase === 'flying') {
const t = Math.min(1, this.phaseElapsed / flightSecs);
const e = easeInOutCubic(t);
this.camera.position.lerpVectors(this.fromPos, this.toPos, e);
this.target.lerpVectors(this.fromTarget, this.toTarget, e);
this.applyDutch(this.beatIndex, e);
if (t >= 1) {
this.phase = 'dwelling';
this.phaseElapsed = 0;
this.cb.onBeat?.(this.path.beats[this.beatIndex], this.beatIndex, this.shotAt(this.beatIndex));
}
} else if (this.phase === 'dwelling') {
if (!this.opts.reducedMotion) {
const nodePos = this.focalPoint(this.path.beats[this.beatIndex]);
if (nodePos) {
this.target.lerp(nodePos, 0.02); // gentle settle keeps the shot alive
// An orbit shot slowly revolves the camera around the node
// during the dwell — the signature "reverent" move for keystones.
if (this.shotAt(this.beatIndex)?.move === 'orbit') {
this.orbitAround(nodePos, dt * 0.35);
}
}
}
if (this.phaseElapsed >= dwellSecs) {
const nextIndex = this.beatIndex + 1;
if (nextIndex >= this.path.beats.length) {
this.phase = 'done';
this.cb.onProgress?.(1);
this.cb.onComplete?.();
return;
}
this.beatIndex = nextIndex;
this.beginFlightTo(nextIndex);
}
}
// Overall progress across the whole tour (beat + intra-beat fraction).
// Guard against an empty path (per = 0) so progress can never be NaN.
const per = this.path.beats.length > 0 ? 1 / this.path.beats.length : 0;
const intra =
this.phase === 'flying'
? Math.min(1, this.phaseElapsed / flightSecs) * 0.5
: 0.5 + Math.min(1, this.phaseElapsed / dwellSecs) * 0.5;
this.cb.onProgress?.(Math.min(1, this.beatIndex * per + intra * per));
}
/** Revolve the camera around a node by `angle` radians (orbit shots). */
private orbitAround(center: THREE.Vector3, angle: number): void {
_tmpDir.copy(this.camera.position).sub(center);
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const x = _tmpDir.x * cos - _tmpDir.z * sin;
const z = _tmpDir.x * sin + _tmpDir.z * cos;
_tmpDir.x = x;
_tmpDir.z = z;
this.camera.position.copy(center).add(_tmpDir);
}
/** Roll the camera (Dutch angle) toward the shot's target roll over the
* flight, easing back to upright for non-Dutch shots. */
private applyDutch(index: number, t: number): void {
const targetRoll = this.shotAt(index)?.dutch ?? 0;
const roll = targetRoll * t;
// camera.up = rotate world-up around the camera's forward axis by `roll`.
_tmpDir.set(0, 0, -1).applyQuaternion(this.camera.quaternion); // forward
this.camera.up.set(0, 1, 0).applyAxisAngle(_tmpDir, roll);
}
}
function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}

View file

@ -0,0 +1,148 @@
// Memory Cinema — narration tiers 1 & 2.
//
// Tier 1 (premium): a backend LLM endpoint (/api/narrative) authors rich prose
// from the planned path. Used only when the backend advertises it.
// Tier 2 (smart local default): deterministic, structured captions generated
// purely from the real node/edge data — no network, no LLM, instant. This is
// what the static HN demo and any backend-without-LLM setup uses.
//
// Tier 3 (the BFS camera engine in director.ts) always runs underneath; the
// narrator only decides what TEXT accompanies each beat. If everything here
// fails, captions fall back to Tier 2, which cannot fail.
import type { CinemaBeat, CinemaPath } from './pathfinder';
export interface BeatNarration {
nodeId: string;
/** The caption shown + optionally spoken for this beat. */
text: string;
/** Short label for the beat kind, shown as a chip. */
chip: string;
}
export type NarrationSource = 'backend-llm' | 'local-captions';
export interface CinemaNarration {
source: NarrationSource;
beats: BeatNarration[];
}
// `satisfies` makes the compiler error if a new CinemaBeat['kind'] is added
// without a chip here — closes the silent "undefined chip → blank UI" gap.
const KIND_CHIP = {
origin: 'Origin',
connection: 'Connection',
contradiction: 'Tension',
recent: 'Now',
bridge: 'Jump',
surprise: 'Surprise',
} satisfies Record<CinemaBeat['kind'], string>;
function snippet(content: string, max = 90): string {
const s = (content ?? '').replace(/\s+/g, ' ').trim();
if (s.length <= max) return s;
return s.slice(0, max - 1).trimEnd() + '…';
}
function typeLabel(nodeType: string): string {
const t = (nodeType ?? 'memory').toLowerCase();
return t.charAt(0).toUpperCase() + t.slice(1);
}
/**
* Tier 2 deterministic structured captions from real data only.
* Never throws; always returns a caption per beat.
*/
export function localCaptions(path: CinemaPath): CinemaNarration {
const beats: BeatNarration[] = path.beats.map((beat, i) => {
const n = beat.node;
const what = snippet(n.label || `(${typeLabel(n.type)} memory)`);
let text: string;
switch (beat.kind) {
case 'origin':
text = `We begin at a ${typeLabel(n.type).toLowerCase()} the graph is centered on — "${what}".`;
break;
case 'contradiction': {
const via = beat.viaEdge?.type ? beat.viaEdge.type.replace(/_/g, ' ') : 'a conflict';
text = `This is held in tension with the last memory through ${via}: "${what}".`;
break;
}
case 'recent':
text = `And where the mind is now — a recent memory: "${what}".`;
break;
case 'bridge':
text = `Crossing to a separate cluster — "${what}".`;
break;
default: {
const w = beat.viaEdge?.weight ?? 0;
const strength = w > 0.66 ? 'strongly' : w > 0.33 ? 'closely' : 'loosely';
text = `${strength} connected from there: a ${typeLabel(n.type).toLowerCase()} — "${what}".`;
}
}
// Tags add texture when present.
if (n.tags && n.tags.length > 0 && i > 0) {
text += ` [${n.tags.slice(0, 3).join(', ')}]`;
}
return { nodeId: beat.nodeId, text, chip: KIND_CHIP[beat.kind] };
});
return { source: 'local-captions', beats };
}
/**
* Resolve the best available narration for a path.
*
* @param fetchBackend optional async fn that returns backend-LLM narration
* beats (Tier 1). If it's absent, rejects, times out, or returns a mismatched
* shape, we silently fall back to Tier 2 local captions. The caller passes
* this only when the backend has advertised /api/narrative support.
*/
export async function resolveNarration(
path: CinemaPath,
fetchBackend?: () => Promise<BeatNarration[] | null>
): Promise<CinemaNarration> {
const fallback = localCaptions(path);
if (!fetchBackend) return fallback;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const backend = await Promise.race([
fetchBackend(),
new Promise<null>((resolve) => {
timer = setTimeout(() => resolve(null), 6000);
}),
]);
// Keep only well-formed backend beats (guards against null/empty/garbage
// entries that would otherwise produce blank captions mid-tour).
const valid = Array.isArray(backend)
? backend.filter(
(b): b is BeatNarration =>
!!b && typeof b.nodeId === 'string' && typeof b.text === 'string' && b.text.trim().length > 0
)
: [];
if (valid.length === 0) return fallback;
// Align backend beats to the real path by nodeId; fill any gap from the
// bounds-safe local caption so every beat always has text (never blank).
const byNode = new Map(valid.map((b) => [b.nodeId, b]));
const beats: BeatNarration[] = path.beats.map((beat, i) => {
const hit = byNode.get(beat.nodeId);
if (hit) {
const chip = typeof hit.chip === 'string' && hit.chip.trim() ? hit.chip : KIND_CHIP[beat.kind];
return { nodeId: beat.nodeId, text: hit.text, chip };
}
return (
fallback.beats[i] ?? {
nodeId: beat.nodeId,
text: beat.node.label || '(unlabeled memory)',
chip: KIND_CHIP[beat.kind],
}
);
});
return { source: 'backend-llm', beats };
} catch {
return fallback;
} finally {
if (timer) clearTimeout(timer);
}
}

View file

@ -0,0 +1,171 @@
// Memory Cinema — Tier 3: the bulletproof pathfinder.
//
// Plans a cinematic tour through the REAL memory graph using nothing but the
// nodes + edges the backend already returns. This is the deterministic engine
// that ALWAYS drives the camera, regardless of which narration tier (backend
// LLM / local captions / none) is active. No WebGPU, no network, no LLM — if
// everything else fails, this still produces a coherent, watchable flythrough.
//
// The path is intentionally a STORY, not a raw BFS dump:
// 1. start at the center (the memory the graph is focused on)
// 2. visit its strongest-weighted connections (what it's most tied to)
// 3. detour to a contradiction edge if one exists (tension = interesting)
// 4. end on a recently-created node (where the mind is now)
// Falling back to plain weighted BFS when those signals are absent.
import type { GraphNode, GraphEdge } from '$types';
export interface CinemaBeat {
/** Node this beat centers the camera on. */
nodeId: string;
/** The node payload, for the narrator + visuals. */
node: GraphNode;
/** Edge traversed to arrive here (null for the opening beat). */
viaEdge: GraphEdge | null;
/** Why this beat exists — drives the deterministic caption + visual emphasis. */
kind: 'origin' | 'connection' | 'contradiction' | 'recent' | 'bridge' | 'surprise';
/** 0..1 emphasis used by the sandbox to spike emissive/bloom on arrival. */
intensity: number;
}
export interface CinemaPath {
beats: CinemaBeat[];
/** The node the requested centerId resolved to (which the tour actually
* starts from). May differ from the requested centerId when it was missing,
* in which case `pivoted` is true callers can surface this if they care. */
centerId: string;
/** True when the requested centerId did not exist and we picked a start node. */
pivoted: boolean;
/** Edges that should visibly "flow" during the tour, in beat order. */
flowEdges: GraphEdge[];
}
export interface Adjacency {
[nodeId: string]: { edge: GraphEdge; otherId: string }[];
}
export function buildAdjacency(edges: GraphEdge[]): Adjacency {
const adj: Adjacency = {};
for (const edge of edges) {
(adj[edge.source] ??= []).push({ edge, otherId: edge.target });
(adj[edge.target] ??= []).push({ edge, otherId: edge.source });
}
// Strongest connections first so the tour visits the most meaningful ties.
for (const id of Object.keys(adj)) {
adj[id].sort((a, b) => (b.edge.weight ?? 0) - (a.edge.weight ?? 0));
}
return adj;
}
export function isContradictionEdge(edge: GraphEdge): boolean {
const t = (edge.type ?? '').toLowerCase();
return t.includes('contradict') || t.includes('conflict') || t.includes('supersede');
}
export function recencyOf(node: GraphNode): number {
// Larger = more recent. Tolerates missing/invalid timestamps.
const t = Date.parse(node.updatedAt || node.createdAt || '');
return Number.isFinite(t) ? t : 0;
}
/**
* Plan a cinematic path over the real graph.
*
* @param maxBeats hard cap on tour length (keeps the flythrough watchable).
* Deterministic: same inputs always yield the same path (no randomness), so the
* recorded launch GIF is reproducible.
*/
export function planCinemaPath(
nodes: GraphNode[],
edges: GraphEdge[],
centerId: string,
maxBeats = 7
): CinemaPath {
const byId = new Map(nodes.map((n) => [n.id, n]));
const empty: CinemaPath = { beats: [], centerId, pivoted: false, flowEdges: [] };
if (nodes.length === 0) return empty;
// Resolve a real starting node: prefer centerId, else the explicit center
// flag, else the most-connected node, else the first node. Track whether we
// had to pivot off the requested centerId so callers can surface it.
const adj = buildAdjacency(edges);
const requestedExists = byId.has(centerId);
let startId = requestedExists ? centerId : '';
if (!startId) startId = nodes.find((n) => n.isCenter)?.id ?? '';
if (!startId) {
startId = nodes
.map((n) => ({ id: n.id, deg: adj[n.id]?.length ?? 0 }))
.sort((a, b) => b.deg - a.deg)[0].id;
}
const start = byId.get(startId);
if (!start) return empty;
const pivoted = !requestedExists;
const visited = new Set<string>([startId]);
const beats: CinemaBeat[] = [
{ nodeId: startId, node: start, viaEdge: null, kind: 'origin', intensity: 1 },
];
const flowEdges: GraphEdge[] = [];
// Greedy weighted walk: from the current frontier, step to the strongest
// unvisited neighbour, with a one-time detour to a contradiction if reachable.
let current = startId;
let contradictionUsed = false;
while (beats.length < maxBeats) {
const neighbours = adj[current] ?? [];
// Prefer an unused contradiction edge once — tension makes a better story.
let next: { edge: GraphEdge; otherId: string } | undefined;
if (!contradictionUsed) {
next = neighbours.find((n) => !visited.has(n.otherId) && isContradictionEdge(n.edge));
if (next) contradictionUsed = true;
}
// Otherwise the strongest unvisited tie.
if (!next) next = neighbours.find((n) => !visited.has(n.otherId));
// Dead end: hop to the most recent unvisited node anywhere (a "bridge"
// cut) so the tour can keep going instead of stalling.
if (!next) {
const remaining = nodes
.filter((n) => !visited.has(n.id))
.sort((a, b) => recencyOf(b) - recencyOf(a));
if (remaining.length === 0) break;
const node = remaining[0];
visited.add(node.id);
beats.push({ nodeId: node.id, node, viaEdge: null, kind: 'bridge', intensity: 0.6 });
current = node.id;
continue;
}
const node = byId.get(next.otherId);
if (!node) {
visited.add(next.otherId);
continue;
}
visited.add(node.id);
flowEdges.push(next.edge);
beats.push({
nodeId: node.id,
node,
viaEdge: next.edge,
kind: isContradictionEdge(next.edge) ? 'contradiction' : 'connection',
intensity: isContradictionEdge(next.edge) ? 1 : Math.min(1, 0.55 + (next.edge.weight ?? 0) * 0.45),
});
current = node.id;
}
// Closing beat: end on the single most-recent node not already the finale,
// so the tour lands on "where the memory is now". Only if it adds variety.
if (beats.length < maxBeats) {
const last = beats[beats.length - 1].nodeId;
const recent = nodes
.filter((n) => n.id !== last)
.sort((a, b) => recencyOf(b) - recencyOf(a))[0];
if (recent && !beats.some((b) => b.nodeId === recent.id)) {
beats.push({ nodeId: recent.id, node: recent, viaEdge: null, kind: 'recent', intensity: 0.8 });
}
}
return { beats, centerId: startId, pivoted, flowEdges };
}

View file

@ -0,0 +1,283 @@
// Memory Cinema — the isolated WebGPU sandbox.
//
// Boots a SEPARATE WebGPU canvas + scene on Cinema launch. The legacy WebGL
// graph (nebula, grain, every current user's experience) is never touched —
// zero regression by construction. Inside the sandbox: the SemanticComputeStorm
// + selective MRT emissive bloom, driven by the CinemaDirector's beats.
//
// Everything here is dynamically imported (three/webgpu, three/tsl, storm.ts)
// so the heavy WebGPU bundle stays out of the main app. If WebGPU is
// unavailable, isSupported() returns false and the caller falls back to the
// camera-only flythrough on the existing canvas (captions still play).
import * as THREE from 'three';
import type { SemanticRole, SemanticComputeStorm } from './storm';
// The storm lives at the world origin, permanently. The camera always looks here
// and is clamped to a safe distance band so the subject can never leave frame.
const ORIGIN = new THREE.Vector3(0, 0, 0);
// Keep the camera in a narrow, fairly FAR band so the contained storm always
// sits comfortably small and centered in frame (a closer camera makes the cloud
// fill — and spill past — the edges once the bloom halo is added).
const MIN_CAM_DIST = 30;
const MAX_CAM_DIST = 44;
export function isWebGPUSupported(): boolean {
return typeof navigator !== 'undefined' && 'gpu' in navigator;
}
interface SandboxDeps {
WebGPURenderer: new (params: object) => {
init: () => Promise<void>;
setSize: (w: number, h: number) => void;
setPixelRatio: (r: number) => void;
renderAsync: (scene: THREE.Scene, camera: THREE.Camera) => Promise<void>;
computeAsync: (node: unknown) => Promise<void>;
domElement: HTMLCanvasElement;
dispose?: () => void;
};
PostProcessing: new (renderer: unknown) => { renderAsync: () => Promise<void>; outputNode: unknown };
StormCtor: typeof SemanticComputeStorm;
tsl: typeof import('three/tsl');
bloomMod: { bloom: (node: unknown, strength?: number, radius?: number, threshold?: number) => unknown };
}
export class CinemaSandbox {
private container: HTMLElement;
private deps!: SandboxDeps;
private renderer!: SandboxDeps['WebGPURenderer']['prototype'];
// Scene/camera are created in boot() from the three/webgpu module so every
// object handed to the WebGPU renderer comes from the SAME Three.js instance
// (avoids the "multiple instances of Three.js" incompatibility — the base
// three import is used only for the shared Vector3 math type the director
// mutates, which is identical across instances).
private scene!: THREE.Scene;
private camera!: THREE.PerspectiveCamera;
private storm!: SemanticComputeStorm;
private post: { renderAsync: () => Promise<void> } | null = null;
private booted = false;
/** Camera target the director drives; mirrored into camera.lookAt each frame. */
readonly target = new THREE.Vector3(0, 0, 0);
// FLYTHROUGH — when >0, relaxes the camera-distance clamp floor so the camera
// can plunge inside the shell, and the storm stretches sprites along the
// apparent motion vector. Camera velocity is derived per-frame from the
// position delta (one Vector3, no compute). 0 = no streak (reduced-motion).
private flythrough = 0;
private prevCamPos = new THREE.Vector3();
private camVel = new THREE.Vector3();
constructor(container: HTMLElement) {
this.container = container;
}
get cameraRef(): THREE.PerspectiveCamera {
return this.camera;
}
/**
* Boot the WebGPU pipeline. Throws if WebGPU is unsupported or init fails
* the caller treats a throw as "fall back to camera-only mode".
*/
async boot(): Promise<void> {
if (this.booted) return;
if (!isWebGPUSupported()) throw new Error('WebGPU not supported');
// Dynamic imports keep three/webgpu out of the main bundle.
const webgpu = (await import('three/webgpu')) as unknown as {
WebGPURenderer: SandboxDeps['WebGPURenderer'];
PostProcessing: SandboxDeps['PostProcessing'];
Scene: new () => THREE.Scene;
PerspectiveCamera: new (fov: number, aspect: number, near: number, far: number) => THREE.PerspectiveCamera;
Color: new (hex: number) => THREE.Color;
};
const tsl = (await import('three/tsl')) as typeof import('three/tsl');
// bloom() lives in the TSL display helpers; import the node module.
const bloomMod = (await import(
'three/examples/jsm/tsl/display/BloomNode.js'
)) as unknown as SandboxDeps['bloomMod'];
const { SemanticComputeStorm } = await import('./storm');
this.deps = {
WebGPURenderer: webgpu.WebGPURenderer,
PostProcessing: webgpu.PostProcessing,
StormCtor: SemanticComputeStorm,
tsl,
bloomMod,
};
// Fail loud if the dynamic import didn't yield the expected constructors,
// instead of a cryptic "undefined is not a constructor" later.
if (!webgpu.WebGPURenderer || !webgpu.Scene || !webgpu.PerspectiveCamera || !webgpu.Color) {
throw new Error('[cinema] three/webgpu is missing expected exports');
}
// Build scene + camera from the SAME (webgpu) module instance the
// renderer + storm use, so all objects are instance-compatible.
const w = Math.max(1, this.container.clientWidth);
const h = Math.max(1, this.container.clientHeight);
this.scene = new webgpu.Scene();
this.scene.background = new webgpu.Color(0x02020a);
this.camera = new webgpu.PerspectiveCamera(60, w / h, 0.1, 2000);
this.camera.position.set(0, 18, 60);
const renderer = new this.deps.WebGPURenderer({ antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(w, h);
// CRITICAL FOOTGUN: WebGPU init is async. Must await before first render
// or the canvas silently draws nothing.
await renderer.init();
this.container.appendChild(renderer.domElement);
this.renderer = renderer;
// The compute storm (150k GPU particles).
this.storm = new this.deps.StormCtor(renderer, this.scene, {});
// Selective MRT bloom: scene pass emits an emissive MRT; bloom only the
// emissive channel so the storm blazes against the void without washing
// the whole frame to grey. Falls back to a plain pass if MRT setup
// throws on a given driver.
try {
const { pass, mrt, output, emissive } = this.deps.tsl as unknown as {
pass: (s: THREE.Scene, c: THREE.Camera) => {
setMRT: (m: unknown) => void;
getTextureNode: (name: string) => unknown;
};
mrt: (cfg: Record<string, unknown>) => unknown;
output: unknown;
emissive: unknown;
};
const scenePass = pass(this.scene, this.camera);
if (typeof scenePass?.setMRT !== 'function' || typeof scenePass?.getTextureNode !== 'function') {
throw new Error('three/tsl pass() API mismatch — setMRT/getTextureNode missing');
}
scenePass.setMRT(mrt({ output, emissive }));
const outputTex = scenePass.getTextureNode('output');
const emissiveTex = scenePass.getTextureNode('emissive');
// Gentler bloom (strength 0.6, threshold 0.35) so it accents the bright
// cores instead of washing the whole colored cloud to white.
const bloomed = this.deps.bloomMod.bloom(emissiveTex, 0.6, 0.65, 0.35);
const post = new this.deps.PostProcessing(renderer);
(post as unknown as { outputNode: unknown }).outputNode = (
outputTex as { add: (n: unknown) => unknown }
).add(bloomed);
this.post = post as unknown as { renderAsync: () => Promise<void> };
} catch (e) {
// MRT/bloom unavailable on this driver — render straight, no crash.
console.warn('[cinema] selective bloom unavailable, rendering without MRT:', e);
this.post = null;
}
this.booted = true;
}
/** Retarget the storm's MODE/ignition for a beat. The storm is permanently
* centered at the WORLD ORIGIN (see render) so it is always dead-center in
* frame worldPos here only conveys which node, not where the storm sits.
* `act` lets the storm hold Act I dimmer (it opens too hot otherwise). */
transitionTo(
role: SemanticRole,
_worldPos: THREE.Vector3,
act: 'I' | 'II' | 'III' = 'II',
beatIndex = 99
): void {
if (!this.booted) return;
this.storm.transitionTo(role, ORIGIN, act, beatIndex);
}
/** Fire one endless-dream beat a random crazier figure + color blast. Called
* on a timer after the scripted tour ends so the storm never sits idle. */
dreamBeat(): void {
if (!this.booted) return;
this.storm.dreamBeat();
}
/** Flythrough strength 0..1. Relaxes the clamp floor so the camera can dive
* inside the shell, and drives the storm's velocity-stretch streak. Set to 0
* for reduced-motion (no streak, normal clamp). */
setFlythrough(s: number): void {
this.flythrough = THREE.MathUtils.clamp(s, 0, 1);
if (this.booted) this.storm.setStreak(s);
}
/** Pass-through: set the storm streak strength directly. */
setStreak(s: number): void {
if (this.booted) this.storm.setStreak(s);
}
/** Pass-through: push view-space camera velocity to the storm. */
setCameraVel(v: THREE.Vector3): void {
if (this.booted) this.storm.setCameraVel(v);
}
/** Render one frame. The storm is pinned to the origin and the camera always
* looks at the origin, so the storm CANNOT leave the frame. The director
* varies only the camera's orbital position/angle (set via cameraRef), and we
* clamp that to a safe distance band here as a final guarantee. */
async render(deltaSeconds: number): Promise<void> {
if (!this.booted) return;
// Camera velocity (world units / sec) from the position delta this frame —
// one Vector3 subtract, no compute. Captured BEFORE the clamp so it tracks
// the director's intended move (the clamp only rescues runaway distances).
this.camVel.copy(this.camera.position).sub(this.prevCamPos).divideScalar(Math.max(deltaSeconds, 1e-3));
this.prevCamPos.copy(this.camera.position);
// Hard guarantee: clamp the camera into a distance band from origin so a
// runaway director move can never push the subject out of view, then look
// dead at the origin where the storm lives. Flythrough relaxes the floor
// toward 6 so the camera can plunge inside the shell; the MAX clamp stands.
const minDist = THREE.MathUtils.lerp(MIN_CAM_DIST, 6, this.flythrough);
const distToOrigin = this.camera.position.length();
if (distToOrigin < minDist || distToOrigin > MAX_CAM_DIST || !Number.isFinite(distToOrigin)) {
const d = Math.min(MAX_CAM_DIST, Math.max(minDist, distToOrigin || MAX_CAM_DIST));
if (distToOrigin > 1e-3) this.camera.position.setLength(d);
else this.camera.position.set(0, 12, d);
}
this.camera.lookAt(ORIGIN);
// Push view-space apparent particle velocity to the storm (negated world
// camera velocity transformed into view space → the direction sprites
// appear to streak). matrixWorldInverse is the previous frame's (the
// renderer refreshes it during renderAsync below) — a one-frame lag that is
// imperceptible for a streak direction.
const camVelView = this.camVel
.clone()
.applyMatrix3(new THREE.Matrix3().setFromMatrix4(this.camera.matrixWorldInverse))
.negate();
this.storm.setCameraVel(camVelView);
// Size the containment sphere to the camera's VERTICAL FOV at the origin
// (the limiting dimension on a landscape frame). 0.82 lets the storm fill
// most of the frame; the storm's internal shell sits well inside this and
// the hard boundary snap keeps the bloom halo from spilling past the edge.
const dist = this.camera.position.length();
const vfov = (this.camera.fov * Math.PI) / 180;
const fitRadius = Math.tan(vfov / 2) * dist * 0.82;
this.storm.setContainRadius(fitRadius);
await this.storm.update(deltaSeconds);
if (this.post) await this.post.renderAsync();
else await this.renderer.renderAsync(this.scene, this.camera);
}
resize(): void {
if (!this.booted) return;
const w = Math.max(1, this.container.clientWidth);
const h = Math.max(1, this.container.clientHeight);
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
this.renderer.setSize(w, h);
}
dispose(): void {
if (!this.booted) return;
this.storm?.dispose();
this.renderer?.dispose?.();
if (this.renderer?.domElement?.parentNode) {
this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
}
this.booted = false;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,225 @@
// The Auteur — graph signal extraction.
//
// Pure, dependency-free statistics over the REAL /api/graph data, computed once
// per Cinema launch. These signals are what gives the AI director something
// meaningful to direct: which memory is most load-bearing (betweenness), where
// tension lives (contradictions), what's surprising (distant-but-plausible
// links), what's fading (low retention / suppression). No LLM, no WebGPU, no
// network — fully headless-testable.
import type { GraphNode, GraphEdge } from '$types';
import { buildAdjacency, recencyOf, isContradictionEdge } from './pathfinder';
export interface NodeSignal {
nodeId: string;
/** Raw connection count. */
degree: number;
/** Brandes betweenness centrality, normalized 0..1 how load-bearing this
* memory is as a bridge between clusters. The director favors high-betweenness
* nodes for hero shots. */
betweenness: number;
/** Connected-component id (which cluster of memory this belongs to). */
clusterId: number;
/** 0..1, 1 = most recent. */
recencyRank: number;
/** FSRS retention 0..1. */
retention: number;
/** Suppression pressure 0..1 (memory actively being forgotten). */
suppression: number;
}
export interface EdgeSignal {
source: string;
target: string;
isContradiction: boolean;
isMergeSupersede: boolean;
/** 0..1: high when endpoints share neighbors (plausible) yet the edge weight
* is low (distant) a surprising, non-obvious connection. */
surprise: number;
weight: number;
}
export interface GraphSignals {
nodes: Map<string, NodeSignal>;
edges: EdgeSignal[];
clusterCount: number;
/** Node id with the single highest betweenness — the graph's keystone. */
peakBetweennessId: string;
}
function isMergeSupersedeEdge(edge: GraphEdge): boolean {
const t = (edge.type ?? '').toLowerCase();
return t.includes('merge') || t.includes('supersede') || t.includes('duplicate');
}
/**
* Brandes' algorithm for betweenness centrality on an unweighted, undirected
* graph. O(V·E) fine for /api/graph payloads. Returns raw (unnormalized)
* scores keyed by node id; the caller normalizes.
*/
function brandesBetweenness(nodeIds: string[], adj: Record<string, { otherId: string }[]>): Map<string, number> {
const cb = new Map<string, number>();
for (const v of nodeIds) cb.set(v, 0);
for (const s of nodeIds) {
const stack: string[] = [];
const pred = new Map<string, string[]>();
const sigma = new Map<string, number>();
const dist = new Map<string, number>();
for (const v of nodeIds) {
pred.set(v, []);
sigma.set(v, 0);
dist.set(v, -1);
}
sigma.set(s, 1);
dist.set(s, 0);
// BFS (unweighted shortest paths).
const queue: string[] = [s];
let head = 0;
while (head < queue.length) {
const v = queue[head++];
stack.push(v);
for (const { otherId: w } of adj[v] ?? []) {
if ((dist.get(w) ?? -1) < 0) {
dist.set(w, (dist.get(v) ?? 0) + 1);
queue.push(w);
}
if ((dist.get(w) ?? -1) === (dist.get(v) ?? 0) + 1) {
sigma.set(w, (sigma.get(w) ?? 0) + (sigma.get(v) ?? 0));
pred.get(w)!.push(v);
}
}
}
// Accumulation (back-propagate dependencies).
const delta = new Map<string, number>();
for (const v of nodeIds) delta.set(v, 0);
while (stack.length > 0) {
const w = stack.pop()!;
for (const v of pred.get(w) ?? []) {
const c = ((sigma.get(v) ?? 0) / (sigma.get(w) || 1)) * (1 + (delta.get(w) ?? 0));
delta.set(v, (delta.get(v) ?? 0) + c);
}
if (w !== s) cb.set(w, (cb.get(w) ?? 0) + (delta.get(w) ?? 0));
}
}
return cb;
}
/** Union-find connected components → a cluster id per node. */
function components(nodeIds: string[], edges: GraphEdge[]): { clusterOf: Map<string, number>; count: number } {
const parent = new Map<string, string>();
for (const id of nodeIds) parent.set(id, id);
const find = (x: string): string => {
let root = x;
while (parent.get(root) !== root) root = parent.get(root)!;
// Path compression.
let cur = x;
while (parent.get(cur) !== root) {
const next = parent.get(cur)!;
parent.set(cur, root);
cur = next;
}
return root;
};
const union = (a: string, b: string) => {
const ra = find(a);
const rb = find(b);
if (ra !== rb) parent.set(ra, rb);
};
for (const e of edges) {
if (parent.has(e.source) && parent.has(e.target)) union(e.source, e.target);
}
const rootToCluster = new Map<string, number>();
const clusterOf = new Map<string, number>();
let next = 0;
for (const id of nodeIds) {
const r = find(id);
if (!rootToCluster.has(r)) rootToCluster.set(r, next++);
clusterOf.set(id, rootToCluster.get(r)!);
}
return { clusterOf, count: next };
}
/**
* Compute all director signals from the real graph. Pure; safe to call once at
* launch. Caps betweenness work on very large graphs by limiting to the
* top-degree subset (the only nodes that can carry meaningful centrality).
*/
export function computeSignals(nodes: GraphNode[], edges: GraphEdge[]): GraphSignals {
const nodeIds = nodes.map((n) => n.id);
const adj = buildAdjacency(edges);
// Recency ranking (0..1, 1 = newest).
const byRecency = [...nodes].sort((a, b) => recencyOf(a) - recencyOf(b));
const recencyRank = new Map<string, number>();
byRecency.forEach((n, i) => recencyRank.set(n.id, nodes.length > 1 ? i / (nodes.length - 1) : 1));
// Betweenness — guard pathological sizes: above the cap, compute on the
// top-degree subset (others get 0; they can't be meaningful bridges anyway).
const BETWEENNESS_CAP = 600;
let betweennessNodes = nodeIds;
if (nodeIds.length > BETWEENNESS_CAP) {
betweennessNodes = [...nodeIds]
.sort((a, b) => (adj[b]?.length ?? 0) - (adj[a]?.length ?? 0))
.slice(0, BETWEENNESS_CAP);
}
const rawBetween = brandesBetweenness(betweennessNodes, adj);
let maxBetween = 0;
for (const v of rawBetween.values()) maxBetween = Math.max(maxBetween, v);
const { clusterOf, count: clusterCount } = components(nodeIds, edges);
const maxSuppression = Math.max(1, ...nodes.map((n) => n.suppression_count ?? 0));
const nodeSignals = new Map<string, NodeSignal>();
let peakBetweennessId = nodeIds[0] ?? '';
let peakVal = -1;
for (const n of nodes) {
const bt = maxBetween > 0 ? (rawBetween.get(n.id) ?? 0) / maxBetween : 0;
if (bt > peakVal) {
peakVal = bt;
peakBetweennessId = n.id;
}
nodeSignals.set(n.id, {
nodeId: n.id,
degree: adj[n.id]?.length ?? 0,
betweenness: bt,
clusterId: clusterOf.get(n.id) ?? 0,
recencyRank: recencyRank.get(n.id) ?? 0,
retention: clamp01(n.retention ?? 0),
suppression: clamp01((n.suppression_count ?? 0) / maxSuppression),
});
}
// Edge signals incl. surprise (shared-neighbor overlap × edge distance).
const neighborSets = new Map<string, Set<string>>();
for (const id of nodeIds) neighborSets.set(id, new Set((adj[id] ?? []).map((a) => a.otherId)));
const edgeSignals: EdgeSignal[] = edges.map((e) => {
const a = neighborSets.get(e.source);
const b = neighborSets.get(e.target);
let shared = 0;
if (a && b) {
const [small, large] = a.size < b.size ? [a, b] : [b, a];
for (const x of small) if (large.has(x)) shared++;
}
const union = (a?.size ?? 0) + (b?.size ?? 0) - shared || 1;
const overlap = shared / union; // Jaccard: structural plausibility.
const distance = 1 - clamp01(e.weight ?? 0); // low weight = semantically distant.
return {
source: e.source,
target: e.target,
isContradiction: isContradictionEdge(e),
isMergeSupersede: isMergeSupersedeEdge(e),
surprise: clamp01(overlap * distance * 2), // plausible AND distant = surprising.
weight: e.weight ?? 0,
};
});
return { nodes: nodeSignals, edges: edgeSignals, clusterCount, peakBetweennessId };
}
function clamp01(x: number): number {
return Math.max(0, Math.min(1, Number.isFinite(x) ? x : 0));
}

View file

@ -6,11 +6,13 @@ const MAX_EVENTS = 200;
function createWebSocketStore() {
const { subscribe, set, update } = writable<{
connected: boolean;
reconnecting: boolean;
events: VestigeEvent[];
lastHeartbeat: VestigeEvent | null;
error: string | null;
}>({
connected: false,
reconnecting: false,
events: [],
lastHeartbeat: null,
error: null
@ -32,7 +34,7 @@ function createWebSocketStore() {
ws.onopen = () => {
reconnectAttempts = 0;
update(s => ({ ...s, connected: true, error: null }));
update(s => ({ ...s, connected: true, reconnecting: false, error: null }));
};
ws.onmessage = (event) => {
@ -65,6 +67,7 @@ function createWebSocketStore() {
function scheduleReconnect(url: string) {
if (reconnectTimer) clearTimeout(reconnectTimer);
update(s => ({ ...s, reconnecting: true }));
const delay = Math.min(1000 * 2 ** reconnectAttempts, 30000);
reconnectAttempts++;
reconnectTimer = setTimeout(() => connect(url), delay);
@ -74,7 +77,7 @@ function createWebSocketStore() {
if (reconnectTimer) clearTimeout(reconnectTimer);
ws?.close();
ws = null;
set({ connected: false, events: [], lastHeartbeat: null, error: null });
set({ connected: false, reconnecting: false, events: [], lastHeartbeat: null, error: null });
}
function clearEvents() {
@ -108,6 +111,7 @@ export const websocket = createWebSocketStore();
// Derived stores for specific event types
export const isConnected = derived(websocket, $ws => $ws.connected);
export const isReconnecting = derived(websocket, $ws => $ws.reconnecting);
export const eventFeed = derived(websocket, $ws => $ws.events);
export const heartbeat = derived(websocket, $ws => $ws.lastHeartbeat);
export const memoryCount = derived(websocket, $ws =>

View file

@ -24,6 +24,11 @@
} from '$components/ActivationNetwork.svelte';
import { filterNewSpreadEvents } from '$components/activation-helpers';
import type { Memory, VestigeEvent } from '$types';
import PageHeader from '$lib/components/PageHeader.svelte';
import Icon from '$lib/components/Icon.svelte';
import AnimatedNumber from '$lib/components/AnimatedNumber.svelte';
import { reveal } from '$lib/actions/reveal';
import { spotlight, magnetic } from '$lib/actions/interactions';
let searchQuery = $state('');
let loading = $state(false);
@ -180,19 +185,30 @@
});
</script>
<div class="p-6 max-w-6xl mx-auto space-y-6">
<header class="space-y-2">
<h1 class="text-xl text-bright font-semibold">Spreading Activation</h1>
<p class="text-xs text-muted">
Collins &amp; Loftus 1975 — activation spreads from a seed memory to
neighbours along semantic edges, decaying by 0.93 per animation frame
until it drops below 0.05. Search seeds a focused burst; live mode
overlays every spread event fired by the cognitive engine in real time.
</p>
</header>
<div class="p-6 max-w-6xl mx-auto space-y-6 enter">
<PageHeader
icon="activation"
title="Spreading Activation"
subtitle="Collins & Loftus 1975 — activation spreads from a seed memory to neighbours along semantic edges, decaying 0.93 per frame until it drops below 0.05. Search seeds a focused burst; live mode overlays every engine spread in real time."
accent="synapse"
>
<div
class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-synapse/10 border border-synapse/25 text-xs"
>
<span
class="ping-host inline-flex h-2 w-2 rounded-full"
style="color: var(--synapse-glow, #8b9dff); background: currentColor;"
class:breathe={liveEnabled}
></span>
<span class="text-dim">{liveEnabled ? 'Live' : 'Paused'}</span>
<span class="text-muted">·</span>
<AnimatedNumber value={liveBurstsFired} class="text-synapse-glow font-semibold" />
<span class="text-muted">bursts</span>
</div>
</PageHeader>
<!-- Search -->
<div class="space-y-3">
<div class="space-y-3" use:reveal={{ delay: 60 }}>
<span class="text-xs text-dim font-medium">Seed Memory</span>
<div class="flex gap-2">
<input
@ -204,10 +220,12 @@
placeholder:text-muted focus:outline-none focus:border-synapse/40 transition backdrop-blur-sm"
/>
<button
use:magnetic
onclick={runSearch}
disabled={loading}
class="px-4 py-2.5 bg-synapse/20 border border-synapse/40 text-synapse-glow text-sm rounded-xl hover:bg-synapse/30 transition disabled:opacity-50"
class="inline-flex items-center gap-1.5 px-4 py-2.5 bg-synapse/20 border border-synapse/40 text-synapse-glow text-sm rounded-xl hover:bg-synapse/30 transition disabled:opacity-50"
>
<Icon name="activation" size={15} />
{loading ? 'Activating…' : 'Activate'}
</button>
</div>
@ -236,8 +254,8 @@
{#if loading}
<div class="flex items-center justify-center h-[560px] text-dim">
<div class="text-center">
<div class="text-2xl animate-pulse mb-2"></div>
<p class="text-sm">Computing activation...</p>
<div class="mx-auto w-fit text-synapse-glow mb-2 breathe"><Icon name="activation" size={32} /></div>
<p class="text-sm">Computing activation</p>
</div>
</div>
{:else if errorMessage}
@ -250,8 +268,8 @@
</div>
{:else if !focusedSource && searched}
<div class="flex items-center justify-center h-[560px] text-dim">
<div class="text-center max-w-md px-6">
<div class="text-3xl opacity-20 mb-3"></div>
<div class="text-center max-w-md px-6 enter">
<div class="mx-auto w-fit text-dim opacity-40 mb-3"><Icon name="search" size={30} /></div>
<p class="text-sm text-bright mb-1">No matching memory</p>
<p class="text-xs text-muted">
Nothing in the graph matches
@ -263,8 +281,8 @@
</div>
{:else if !focusedSource}
<div class="flex items-center justify-center h-[560px] text-dim">
<div class="text-center max-w-md px-6">
<div class="text-3xl opacity-20 mb-3"></div>
<div class="text-center max-w-md px-6 enter">
<div class="mx-auto w-fit text-synapse-glow opacity-40 mb-3 breathe"><Icon name="activation" size={32} /></div>
<p class="text-sm text-bright mb-1">Waiting for activation</p>
<p class="text-xs text-muted">
Seed a burst with the search bar above, or enable live mode to

View file

@ -1,5 +1,10 @@
<script lang="ts">
import ContradictionArcs, { type Contradiction } from '$components/ContradictionArcs.svelte';
import PageHeader from '$components/PageHeader.svelte';
import Dropdown, { type DropdownOption } from '$components/Dropdown.svelte';
import Icon from '$components/Icon.svelte';
import AnimatedNumber from '$components/AnimatedNumber.svelte';
import { reveal } from '$lib/actions/reveal';
import {
severityColor,
severityLabel,
@ -297,6 +302,33 @@
Array.from(new Set(MOCK_CONTRADICTIONS.map((c) => c.topic))).sort()
);
// --- Clear, labelled dropdown options replace the bare filter buttons +
// native <select>. These only drive the *control*, not the filter math:
// `filterOptions` writes into the same `filter` state, `topicOptions` into
// the same `topicFilter` state. ---
const filterOptions: DropdownOption[] = [
{ value: 'all', label: 'All contradictions', icon: 'contradictions' },
{ value: 'recent', label: 'Recent (last 7 days)', icon: 'timeline' },
{ value: 'high-trust', label: 'High trust (>60%)', icon: 'importance' },
{ value: 'topic', label: 'By topic', icon: 'filter' },
];
const topicOptions = $derived<DropdownOption[]>([
{ value: '', label: 'All topics' },
...uniqueTopics.map((t) => ({
value: t,
label: t,
badge: MOCK_CONTRADICTIONS.filter((c) => c.topic === t).length,
})),
]);
// The Dropdown emits string values; keep the filter-reset behaviour the
// old buttons had (clearing focus when the lens changes) without altering
// what each filter selects.
function onFilterChange(v: string) {
filter = v as Filter;
focusedPairIndex = null;
}
const filtered = $derived.by<Contradiction[]>(() => {
switch (filter) {
case 'recent':
@ -361,71 +393,76 @@
<div class="min-h-full p-6 space-y-6">
<!-- Header -->
<header class="space-y-1">
<h1 class="text-2xl text-bright font-semibold tracking-tight">
Contradiction Constellation
</h1>
<p class="text-sm text-dim">Where your memory disagrees with itself</p>
</header>
<PageHeader
icon="contradictions"
title="Contradiction Constellation"
subtitle="Where your memory disagrees with itself"
accent="warning"
>
<span class="text-dim text-sm tabular-nums inline-flex items-center gap-1.5">
<AnimatedNumber value={filtered.length} /> in view
</span>
</PageHeader>
<!-- Stats bar -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div class="p-4 glass rounded-xl">
<div class="text-2xl text-bright font-bold">{TOTAL_CONTRADICTIONS_DETECTED}</div>
<div use:reveal={{ delay: 0, y: 12 }} class="p-4 glass rounded-xl lift">
<div class="text-2xl text-bright font-bold tabular-nums">
<AnimatedNumber value={TOTAL_CONTRADICTIONS_DETECTED} />
</div>
<div class="text-xs text-dim mt-1">
contradictions across {totalMemoriesInvolved.toLocaleString()} memories
</div>
</div>
<div class="p-4 glass rounded-xl">
<div class="text-2xl font-bold" style="color: #f59e0b">
{avgTrustDelta.toFixed(2)}
<div use:reveal={{ delay: 60, y: 12 }} class="p-4 glass rounded-xl lift">
<div class="text-2xl font-bold tabular-nums" style="color: #f59e0b">
<AnimatedNumber value={avgTrustDelta} decimals={2} />
</div>
<div class="text-xs text-dim mt-1">average trust delta</div>
</div>
<div class="p-4 glass rounded-xl">
<div class="text-2xl text-bright font-bold">{filtered.length}</div>
<div use:reveal={{ delay: 120, y: 12 }} class="p-4 glass rounded-xl lift">
<div class="text-2xl text-bright font-bold tabular-nums">
<AnimatedNumber value={filtered.length} />
</div>
<div class="text-xs text-dim mt-1">visible in current filter</div>
</div>
<div class="p-4 glass rounded-xl">
<div class="text-2xl font-bold" style="color: #ef4444">
{filtered.filter((c) => c.similarity > 0.7).length}
<div use:reveal={{ delay: 180, y: 12 }} class="p-4 glass rounded-xl lift">
<div class="flex items-center gap-2">
<span class="ping-host inline-flex">
<span class="w-2 h-2 rounded-full" style="background: #ef4444"></span>
</span>
<div class="text-2xl font-bold tabular-nums" style="color: #ef4444">
<AnimatedNumber value={filtered.filter((c) => c.similarity > 0.7).length} />
</div>
</div>
<div class="text-xs text-dim mt-1">strong conflicts</div>
</div>
</div>
<!-- Filter bar -->
<div class="flex flex-wrap gap-2 items-center">
{#each [{ id: 'all', label: 'All' }, { id: 'recent', label: 'Recent (7d)' }, { id: 'high-trust', label: 'High trust (>60%)' }, { id: 'topic', label: 'By topic' }] as f (f.id)}
<button
onclick={() => {
filter = f.id as Filter;
focusedPairIndex = null;
}}
class="px-3 py-1.5 rounded-lg text-xs border transition
{filter === f.id
? 'bg-synapse/15 border-synapse/40 text-synapse-glow'
: 'border-subtle/30 text-dim hover:text-text hover:bg-white/[0.03]'}"
>
{f.label}
</button>
{/each}
<div class="flex flex-wrap gap-3 items-end enter">
<Dropdown
options={filterOptions}
value={filter}
label="Lens"
icon="filter"
onChange={onFilterChange}
/>
{#if filter === 'topic'}
<select
<Dropdown
options={topicOptions}
bind:value={topicFilter}
class="ml-2 px-3 py-1.5 rounded-lg text-xs glass-subtle border border-subtle/30 text-text"
>
<option value="">All topics</option>
{#each uniqueTopics as t}
<option value={t}>{t}</option>
{/each}
</select>
label="Topic"
icon="contradictions"
placeholder="All topics"
/>
{/if}
{#if focusedPairIndex !== null}
<button
onclick={() => (focusedPairIndex = null)}
class="ml-auto px-3 py-1.5 rounded-lg text-xs border border-subtle/30 text-dim hover:text-text"
class="ml-auto inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs border border-subtle/30 text-dim hover:text-text hover:border-synapse/30 hover:bg-white/[0.03] transition lift"
>
<Icon name="close" size={13} />
Clear focus
</button>
{/if}
@ -433,11 +470,16 @@
<!-- Main view: constellation + sidebar -->
<div class="grid grid-cols-1 lg:grid-cols-[1fr_340px] gap-4">
<!-- Constellation -->
<!-- Constellation. NOTE: no `use:reveal` on this wrapper or the
ContradictionArcs SVG container — a transform/opacity entrance here
would interfere with the constellation's own layout. -->
<div class="glass-panel rounded-2xl p-3 min-h-[520px] relative">
{#if filtered.length === 0}
<div class="flex items-center justify-center h-full text-dim text-sm">
No contradictions match this filter.
<div class="flex flex-col items-center justify-center h-full gap-3 text-center">
<div class="text-dim opacity-50 breathe">
<Icon name="contradictions" size={44} strokeWidth={1.2} />
</div>
<p class="text-dim text-sm">No contradictions match this filter.</p>
</div>
{:else}
<ContradictionArcs
@ -451,10 +493,10 @@
</div>
<!-- Sidebar: pair list -->
<aside class="glass rounded-2xl p-3 space-y-2 max-h-[620px] overflow-y-auto">
<aside use:reveal={{ delay: 120, y: 16 }} class="glass rounded-2xl p-3 space-y-2 max-h-[620px] overflow-y-auto">
<div class="flex items-center justify-between px-1 pb-2 sticky top-0 bg-deep/60 backdrop-blur-sm z-10">
<span class="text-xs text-dim uppercase tracking-wider">Pairs</span>
<span class="text-xs text-muted">{visibleList.length}</span>
<span class="text-xs text-muted tabular-nums"><AnimatedNumber value={visibleList.length} /></span>
</div>
{#if visibleList.length === 0}
@ -465,8 +507,9 @@
{@const c = entry.c}
{@const isFocused = focusedPairIndex === localIndex}
<button
use:reveal={{ delay: Math.min(localIndex * 35, 350), y: 10 }}
onclick={() => sidebarClick(localIndex)}
class="w-full text-left p-3 rounded-xl border transition
class="w-full text-left p-3 rounded-xl border transition lift
{isFocused
? 'bg-synapse/10 border-synapse/40 shadow-[0_0_12px_rgba(99,102,241,0.18)]'
: 'border-subtle/20 hover:border-synapse/30 hover:bg-white/[0.02]'}"

View file

@ -11,6 +11,11 @@
import type { DreamResult } from '$types';
import DreamStageReplay from '$components/DreamStageReplay.svelte';
import DreamInsightCard from '$components/DreamInsightCard.svelte';
import PageHeader from '$components/PageHeader.svelte';
import Icon from '$components/Icon.svelte';
import AnimatedNumber from '$components/AnimatedNumber.svelte';
import { reveal } from '$lib/actions/reveal';
import { spotlight, magnetic } from '$lib/actions/interactions';
import {
STAGE_NAMES,
clampStage,
@ -59,49 +64,73 @@
<title>Dream Cinema · Vestige</title>
</svelte:head>
<div class="p-6 max-w-7xl mx-auto space-y-6">
<div class="p-6 max-w-7xl mx-auto space-y-6 enter">
<!-- Header -->
<header class="flex items-start justify-between flex-wrap gap-4">
<div>
<h1 class="text-2xl text-bright font-semibold tracking-tight flex items-center gap-3">
<span class="header-glyph"></span>
Dream Cinema
</h1>
<p class="text-sm text-dim mt-1 max-w-xl leading-snug">
Scrub through Vestige's 5-stage consolidation cycle. Replay, cross-reference,
strengthen, prune, transfer. Watch episodic become semantic.
</p>
</div>
<button
type="button"
onclick={runDream}
disabled={dreaming}
class="dream-button"
class:is-dreaming={dreaming}
>
<PageHeader
icon="dreams"
title="Dream Cinema"
subtitle="Scrub through Vestige's 5-stage consolidation cycle. Replay, cross-reference, strengthen, prune, transfer. Watch episodic become semantic."
accent="dream"
>
<div class="flex items-center gap-3">
{#if dreaming}
<span class="spinner" aria-hidden="true"></span>
<span>Dreaming...</span>
{:else}
<span class="dream-icon" aria-hidden="true"></span>
<span>Dream Now</span>
<span class="live-status">
<span class="ping-host live-dot" style="color: var(--color-dream-glow); background: var(--color-dream-glow);"></span>
<span>Consolidating</span>
</span>
{:else if hasDream}
<span class="live-status idle">
<span class="live-dot breathe" style="background: var(--color-synapse-glow);"></span>
<span>Cycle complete</span>
</span>
{/if}
</button>
</header>
<button
type="button"
onclick={runDream}
disabled={dreaming}
class="dream-button"
class:is-dreaming={dreaming}
use:magnetic
>
{#if dreaming}
<span class="spinner" aria-hidden="true"></span>
<span>Dreaming...</span>
{:else}
<span class="dream-icon" aria-hidden="true"><Icon name="sparkle" size={16} /></span>
<span>Dream Now</span>
{/if}
</button>
</div>
</PageHeader>
{#if error}
<div class="glass-subtle rounded-xl px-4 py-3 text-sm border !border-decay/40 text-decay">
{error}
<div class="glass-subtle rounded-xl px-4 py-3 text-sm border !border-decay/40 text-decay flex items-center gap-2" use:reveal>
<Icon name="contradictions" size={16} />
<span>{error}</span>
</div>
{/if}
{#if !hasDream && !dreaming}
<!-- Empty state -->
<div class="empty-state glass-panel rounded-2xl p-12 text-center space-y-3">
<div class="empty-glyph"></div>
<p class="text-bright font-semibold">No dream yet.</p>
<p class="text-dim text-sm">Click Dream Now to begin.</p>
<div class="empty-state glass-panel rounded-2xl p-12 text-center space-y-4" use:reveal>
<div class="empty-glyph" aria-hidden="true">
<Icon name="dreams" size={52} draw />
</div>
<p class="text-bright font-semibold text-lg">Nothing's been dreamt yet.</p>
<p class="text-dim text-sm max-w-sm mx-auto leading-relaxed">
Run a consolidation cycle and watch your episodic memories replay,
cross-reference, and crystallize into lasting semantic knowledge.
</p>
<button
type="button"
onclick={runDream}
class="dream-button mx-auto"
use:magnetic
>
<span class="dream-icon" aria-hidden="true"><Icon name="sparkle" size={16} /></span>
<span>Start the first dream</span>
</button>
</div>
{:else}
<!-- Scrubber + stage markers -->
@ -218,20 +247,6 @@
</div>
<style>
.header-glyph {
display: inline-block;
color: var(--color-dream-glow);
text-shadow:
0 0 12px var(--color-dream),
0 0 24px color-mix(in srgb, var(--color-dream) 50%, transparent);
animation: twinkle 4s ease-in-out infinite;
}
@keyframes twinkle {
0%, 100% { opacity: 1; transform: rotate(0deg); }
50% { opacity: 0.75; transform: rotate(10deg); }
}
.dream-button {
display: inline-flex;
align-items: center;

View file

@ -9,6 +9,11 @@
import { onMount, onDestroy } from 'svelte';
import DuplicateCluster from '$components/DuplicateCluster.svelte';
import { clusterKey, filterByThreshold } from '$components/duplicates-helpers';
import PageHeader from '$lib/components/PageHeader.svelte';
import Icon from '$lib/components/Icon.svelte';
import AnimatedNumber from '$lib/components/AnimatedNumber.svelte';
import { reveal } from '$lib/actions/reveal';
import { spotlight } from '$lib/actions/interactions';
interface ClusterMemory {
id: string;
@ -255,15 +260,20 @@
<div class="relative mx-auto max-w-5xl space-y-6 p-6">
<!-- Header -->
<header class="space-y-2">
<h1 class="text-xl font-semibold text-bright">
Memory Hygiene — Duplicate Detection
</h1>
<p class="text-sm text-dim">
Cosine-similarity clustering over embeddings. Merges reinforce the winner's FSRS state;
losers inherit into the merged node. Dismissed clusters are hidden for this session only.
</p>
</header>
<PageHeader
icon="duplicates"
title="Memory Hygiene — Duplicate Detection"
subtitle="Cosine-similarity clustering over embeddings. Merges reinforce the winner's FSRS state; losers inherit into the merged node. Dismissed clusters are hidden for this session only."
accent="synapse"
>
<span
class="ping-host flex h-2 w-2 items-center justify-center text-synapse-glow"
aria-hidden="true"
>
<span class="breathe h-2 w-2 rounded-full bg-synapse-glow"></span>
</span>
<span class="text-xs text-dim">Live</span>
</PageHeader>
<!-- Controls panel -->
<div class="glass-panel flex flex-wrap items-center gap-5 rounded-2xl p-4">
@ -292,17 +302,19 @@
aria-live="polite"
>
{#if loading}
<span class="h-2 w-2 animate-pulse rounded-full bg-synapse-glow"></span>
<span class="breathe h-2 w-2 rounded-full bg-synapse-glow text-synapse-glow"></span>
<span>Detecting…</span>
{:else if error}
<span class="h-2 w-2 rounded-full bg-decay"></span>
<span class="text-decay">Error</span>
{:else}
<span class="h-2 w-2 rounded-full bg-synapse-glow"></span>
<span>
{visibleClusters.length}
<span class="breathe h-2 w-2 rounded-full bg-synapse-glow text-synapse-glow"></span>
<span class="tabular-nums">
<AnimatedNumber value={visibleClusters.length} />
{visibleClusters.length === 1 ? 'cluster' : 'clusters'},
{totalDuplicates} potential duplicate{totalDuplicates === 1 ? '' : 's'}
<AnimatedNumber value={totalDuplicates} /> potential duplicate{totalDuplicates === 1
? ''
: 's'}
</span>
{/if}
</div>
@ -335,18 +347,25 @@
{:else if loading}
<div class="space-y-3">
{#each Array(3) as _}
<div class="glass-subtle h-40 animate-pulse rounded-2xl"></div>
<div class="glass-subtle shimmer h-40 rounded-2xl"></div>
{/each}
</div>
{:else if visibleClusters.length === 0}
<div
class="glass-panel flex flex-col items-center gap-2 rounded-2xl p-12 text-center"
class="glass-panel enter flex flex-col items-center gap-3 rounded-2xl p-12 text-center"
>
<div class="text-3xl">·</div>
<div class="text-sm font-medium text-bright">
No duplicates found above threshold.
<div
class="flex h-14 w-14 items-center justify-center rounded-2xl border border-recall/25 bg-recall/10 text-recall"
>
<Icon name="sparkle" size={26} draw />
</div>
<div class="text-sm font-medium text-bright">
No duplicates found — your memory is clean.
</div>
<div class="max-w-sm text-xs text-muted">
Nothing clusters above {(threshold * 100).toFixed(0)}% similarity. Lower the threshold to
surface looser matches.
</div>
<div class="text-xs text-muted">Memory is clean.</div>
</div>
{:else}
<div class="space-y-4">
@ -358,30 +377,23 @@
threshold to narrow results.
</div>
{/if}
{#each renderedClusters as { c, key } (key)}
<div class="animate-[fadeSlide_0.35s_ease-out_both]">
<DuplicateCluster
similarity={c.similarity}
memories={c.memories}
suggestedAction={c.suggestedAction}
onDismiss={() => dismissCluster(key)}
onMerge={(winnerId, loserIds) => mergeCluster(key, winnerId, loserIds)}
/>
{#each renderedClusters as { c, key }, i (key)}
<div
class="spotlight-surface lift rounded-2xl"
use:reveal={{ delay: Math.min(i * 40, 400), y: 14 }}
use:spotlight
>
<div class="relative z-[1]">
<DuplicateCluster
similarity={c.similarity}
memories={c.memories}
suggestedAction={c.suggestedAction}
onDismiss={() => dismissCluster(key)}
onMerge={(winnerId, loserIds) => mergeCluster(key, winnerId, loserIds)}
/>
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
@keyframes fadeSlide {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
</div>

View file

@ -1,6 +1,11 @@
<script lang="ts">
import { api } from '$stores/api';
import type { Memory } from '$types';
import PageHeader from '$lib/components/PageHeader.svelte';
import Icon, { type IconName } from '$lib/components/Icon.svelte';
import AnimatedNumber from '$lib/components/AnimatedNumber.svelte';
import { reveal } from '$lib/actions/reveal';
import { spotlight } from '$lib/actions/interactions';
let searchQuery = $state('');
let targetQuery = $state('');
@ -12,10 +17,10 @@
let importanceText = $state('');
let importanceResult: Record<string, unknown> | null = $state(null);
const MODE_INFO: Record<string, { icon: string; desc: string }> = {
associations: { icon: '', desc: 'Spreading activation — find related memories via graph traversal' },
chains: { icon: '', desc: 'Build reasoning path from source to target memory' },
bridges: { icon: '', desc: 'Find connecting memories between two concepts' },
const MODE_INFO: Record<string, { icon: IconName; desc: string }> = {
associations: { icon: 'activation', desc: 'Spreading activation — find related memories via graph traversal' },
chains: { icon: 'reasoning', desc: 'Build reasoning path from source to target memory' },
bridges: { icon: 'explore', desc: 'Find connecting memories between two concepts' },
};
async function findSource() {
@ -68,17 +73,22 @@
</script>
<div class="p-6 max-w-5xl mx-auto space-y-8">
<h1 class="text-xl text-bright font-semibold">Explore Connections</h1>
<PageHeader
icon="explore"
title="Explore Connections"
subtitle="Traverse the memory graph — spreading activation, reasoning chains, and conceptual bridges."
accent="synapse"
/>
<!-- Mode selector -->
<div class="grid grid-cols-3 gap-2">
{#each (['associations', 'chains', 'bridges'] as const) as m}
<button onclick={() => switchMode(m)}
class="flex flex-col items-center gap-1 p-3 rounded-xl text-sm transition
class="lift flex flex-col items-center gap-1 p-3 rounded-xl text-sm transition
{mode === m
? 'glass !border-synapse/30 text-synapse-glow'
: 'glass-subtle text-dim hover:bg-white/[0.03]'}">
<span class="text-xl">{MODE_INFO[m].icon}</span>
<span class="{mode === m ? 'breathe' : ''}"><Icon name={MODE_INFO[m].icon} size={22} /></span>
<span class="font-medium">{m.charAt(0).toUpperCase() + m.slice(1)}</span>
<span class="text-[10px] text-muted text-center">{MODE_INFO[m].desc}</span>
</button>
@ -144,29 +154,51 @@
<!-- Results -->
{#if sourceMemory}
{#if loading}
<div class="text-center py-8 text-dim">
<div class="text-lg animate-pulse mb-2"></div>
<p>Exploring {mode}...</p>
<div class="space-y-3" aria-busy="true">
<div class="flex items-center gap-2.5 text-dim">
<Icon name="activation" size={18} class="breathe text-synapse-glow" />
<span class="text-sm">Exploring {mode}</span>
</div>
<div class="space-y-2">
{#each Array(4) as _, i}
<div class="shimmer p-3 glass-subtle rounded-xl flex items-start gap-3">
<div class="shimmer w-6 h-6 rounded-full bg-white/[0.05] flex-shrink-0 mt-0.5"></div>
<div class="flex-1 min-w-0 space-y-2">
<div class="shimmer h-3.5 rounded bg-white/[0.05]" style="width: {88 - i * 9}%"></div>
<div class="shimmer h-3 rounded bg-white/[0.04]" style="width: {52 - i * 6}%"></div>
</div>
</div>
{/each}
</div>
</div>
{:else if associations.length > 0}
<div class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-sm text-bright font-semibold">{associations.length} Connections Found</h2>
<h2 class="text-sm text-bright font-semibold flex items-baseline gap-1.5">
<AnimatedNumber value={associations.length} class="text-aurora font-bold" />
<span>Connections Found</span>
</h2>
</div>
<div class="space-y-2">
{#each associations as assoc, i}
<div class="p-3 glass-subtle rounded-xl flex items-start gap-3 hover:bg-white/[0.03] transition">
<div class="w-6 h-6 rounded-full bg-synapse/15 text-synapse-glow text-xs flex items-center justify-center flex-shrink-0 mt-0.5">
{i + 1}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm text-text line-clamp-2">{assoc.content}</p>
<div class="flex flex-wrap gap-3 mt-1.5 text-xs text-muted">
{#if assoc.nodeType}<span class="px-1.5 py-0.5 bg-white/[0.04] rounded">{assoc.nodeType}</span>{/if}
{#if assoc.score}<span>Score: {Number(assoc.score).toFixed(3)}</span>{/if}
{#if assoc.similarity}<span>Similarity: {Number(assoc.similarity).toFixed(3)}</span>{/if}
{#if assoc.retention}<span>{(Number(assoc.retention) * 100).toFixed(0)}% retention</span>{/if}
{#if assoc.connectionType}<span class="text-synapse-glow">{assoc.connectionType}</span>{/if}
{#each associations as assoc, i (i)}
<div
use:reveal={{ delay: Math.min(i * 35, 350), y: 12 }}
use:spotlight
class="spotlight-surface lift p-3 glass-subtle rounded-xl hover:bg-white/[0.03] transition"
>
<div class="relative z-[1] flex items-start gap-3">
<div class="w-6 h-6 rounded-full bg-synapse/15 text-synapse-glow text-xs flex items-center justify-center flex-shrink-0 mt-0.5 tabular-nums">
{i + 1}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm text-text line-clamp-2">{assoc.content}</p>
<div class="flex flex-wrap gap-3 mt-1.5 text-xs text-muted">
{#if assoc.nodeType}<span class="px-1.5 py-0.5 bg-white/[0.04] rounded">{assoc.nodeType}</span>{/if}
{#if assoc.score}<span class="tabular-nums">Score: {Number(assoc.score).toFixed(3)}</span>{/if}
{#if assoc.similarity}<span class="tabular-nums">Similarity: {Number(assoc.similarity).toFixed(3)}</span>{/if}
{#if assoc.retention}<span class="tabular-nums">{(Number(assoc.retention) * 100).toFixed(0)}% retention</span>{/if}
{#if assoc.connectionType}<span class="text-synapse-glow">{assoc.connectionType}</span>{/if}
</div>
</div>
</div>
</div>
@ -174,16 +206,26 @@
</div>
</div>
{:else}
<div class="text-center py-8 text-dim">
<div class="text-3xl mb-3 opacity-20"></div>
<p>No connections found for this query.</p>
<div class="enter text-center py-12 px-6 glass-subtle rounded-2xl">
<Icon name="explore" size={40} class="breathe text-synapse-glow mx-auto mb-4 opacity-80" />
<p class="text-sm text-bright font-medium">No connections surfaced yet</p>
<p class="text-xs text-muted mt-1.5 max-w-sm mx-auto">
{#if mode === 'associations'}
This memory hasn't formed strong links here. Try a broader source query — the graph rewards more general seeds.
{:else}
No {mode} found between these two memories. Pick a different source or target and the path may light up.
{/if}
</p>
</div>
{/if}
{/if}
<!-- Importance Scorer -->
<div class="pt-8 border-t border-synapse/10">
<h2 class="text-lg text-bright font-semibold mb-4">Importance Scorer</h2>
<h2 class="text-lg text-bright font-semibold mb-4 flex items-center gap-2">
<Icon name="importance" size={20} class="text-recall" />
Importance Scorer
</h2>
<p class="text-xs text-muted mb-3">4-channel neuroscience scoring: novelty, arousal, reward, attention</p>
<textarea
bind:value={importanceText}
@ -199,9 +241,9 @@
{#if importanceResult}
{@const channels = importanceResult.channels as Record<string, number> | undefined}
{@const composite = Number(importanceResult.composite || importanceResult.compositeScore || 0)}
<div class="mt-4 p-4 glass rounded-xl">
<div class="enter mt-4 p-4 glass rounded-xl">
<div class="flex items-center gap-3 mb-4">
<span class="text-3xl text-bright font-bold">{composite.toFixed(2)}</span>
<AnimatedNumber value={composite} decimals={2} class="text-3xl text-aurora font-bold" />
<span class="px-2 py-1 rounded-lg text-xs {composite > 0.6
? 'bg-recall/20 text-recall border border-recall/30'
: 'bg-white/[0.04] text-dim border border-subtle/20'}">

View file

@ -1,32 +1,38 @@
<script lang="ts">
import { eventFeed, websocket } from '$stores/websocket';
import { eventFeed, websocket, isConnected, isReconnecting } from '$stores/websocket';
import { EVENT_TYPE_COLORS, type VestigeEvent } from '$types';
import PipelineVisualizer from '$components/PipelineVisualizer.svelte';
import PageHeader from '$components/PageHeader.svelte';
import Icon, { type IconName } from '$components/Icon.svelte';
import AnimatedNumber from '$components/AnimatedNumber.svelte';
import { reveal } from '$lib/actions/reveal';
function formatTime(ts: string): string {
return new Date(ts).toLocaleTimeString();
}
function eventIcon(type: string): string {
const icons: Record<string, string> = {
MemoryCreated: '+',
MemoryUpdated: '~',
MemoryDeleted: '×',
MemoryPromoted: '↑',
MemoryDemoted: '↓',
SearchPerformed: '◎',
DreamStarted: '◈',
DreamProgress: '◈',
DreamCompleted: '◈',
ConsolidationStarted: '◉',
ConsolidationCompleted: '◉',
RetentionDecayed: '↘',
ConnectionDiscovered: '━',
ActivationSpread: '◬',
ImportanceScored: '◫',
Heartbeat: '♡',
// Map each cognitive event type to a drawn route-icon so the feed speaks the
// same visual language as the rest of the dashboard (no more Unicode glyphs).
function eventIcon(type: string): IconName {
const icons: Record<string, IconName> = {
MemoryCreated: 'memories',
MemoryUpdated: 'memories',
MemoryDeleted: 'close',
MemoryPromoted: 'importance',
MemoryDemoted: 'importance',
SearchPerformed: 'search',
DreamStarted: 'dreams',
DreamProgress: 'dreams',
DreamCompleted: 'dreams',
ConsolidationStarted: 'activation',
ConsolidationCompleted: 'activation',
RetentionDecayed: 'timeline',
ConnectionDiscovered: 'graph',
ActivationSpread: 'activation',
ImportanceScored: 'importance',
Heartbeat: 'pulse',
};
return icons[type] || '·';
return icons[type] || 'sparkle';
}
function eventSummary(event: VestigeEvent): string {
@ -45,44 +51,84 @@
default: return JSON.stringify(d).slice(0, 100);
}
}
// Connection state drives the live pill — connected pings, reconnecting
// breathes amber, offline goes quiet. Computed once, reused in the header.
let pill = $derived(
$isConnected
? { color: 'var(--color-recall)', label: 'Live', live: true }
: $isReconnecting
? { color: 'var(--color-importance, #f59e0b)', label: 'Reconnecting', live: false }
: { color: 'var(--color-decay, #8B95A5)', label: 'Offline', live: false }
);
</script>
<div class="p-6 max-w-4xl mx-auto space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-xl text-bright font-semibold">Live Feed</h1>
<div class="flex gap-3">
<span class="text-dim text-sm">{$eventFeed.length} events</span>
<button onclick={() => websocket.clearEvents()}
class="text-xs text-muted hover:text-text transition">Clear</button>
</div>
</div>
<div class="p-6 max-w-4xl mx-auto space-y-6 enter">
<PageHeader
icon="feed"
title="Live Feed"
subtitle="Every thought as it happens — memories born, searches fired, dreams consolidating, connections discovered. Vestige, thinking out loud."
accent="synapse"
>
<span class="live-status" class:idle={!pill.live}>
<span
class="live-dot w-2 h-2 rounded-full"
class:ping-host={pill.live}
class:breathe={!pill.live}
style="color:{pill.color};background:{pill.color}"
></span>
<span>{pill.label}</span>
</span>
<span class="count-chip">
<AnimatedNumber value={$eventFeed.length} class="text-bright font-semibold" />
<span class="text-dim">events</span>
</span>
<button
onclick={() => websocket.clearEvents()}
class="clear-btn"
disabled={$eventFeed.length === 0}
>
<Icon name="close" size={13} />
<span>Clear</span>
</button>
</PageHeader>
{#if $eventFeed.length === 0}
<div class="text-center py-20 text-dim">
<div class="text-4xl mb-4"></div>
<p>Waiting for cognitive events...</p>
<p class="text-sm text-muted mt-2">Events appear here in real-time as Vestige thinks.</p>
<div class="empty-state glass-panel rounded-2xl p-12 text-center space-y-4" use:reveal>
<div class="empty-glyph" aria-hidden="true">
<Icon name="feed" size={52} draw />
</div>
<p class="text-bright font-semibold text-lg">Quiet for now.</p>
<p class="text-dim text-sm max-w-sm mx-auto leading-relaxed">
New memory activity will stream in here the moment it happens — the feed
wakes up the instant Vestige does.
</p>
</div>
{:else}
<div class="space-y-2">
{#each $eventFeed as event, i (i)}
{@const color = EVENT_TYPE_COLORS[event.type] || '#8B95A5'}
<div
class="flex items-start gap-3 p-3 glass-subtle rounded-xl
hover:bg-white/[0.03] transition-all duration-200"
style="border-left: 3px solid {EVENT_TYPE_COLORS[event.type] || '#8B95A5'}"
use:reveal={{ delay: Math.min(i * 30, 300), y: 10 }}
class="event-row lift flex items-start gap-3 p-3 glass-subtle rounded-xl"
style="border-left: 3px solid {color}; --evt: {color};"
>
<div class="w-6 h-6 rounded flex items-center justify-center text-xs flex-shrink-0"
style="background: {EVENT_TYPE_COLORS[event.type] || '#8B95A5'}15; color: {EVENT_TYPE_COLORS[event.type] || '#8B95A5'}">
{eventIcon(event.type)}
<div
class="event-icon flex items-center justify-center flex-shrink-0"
style="background: {color}1a; color: {color}"
>
<Icon name={eventIcon(event.type)} size={15} />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-0.5">
<span class="text-xs font-medium" style="color: {EVENT_TYPE_COLORS[event.type] || '#8B95A5'}">{event.type}</span>
<span class="text-xs font-semibold tracking-wide" style="color: {color}">{event.type}</span>
{#if event.data.timestamp}
<span class="text-xs text-muted">{formatTime(String(event.data.timestamp))}</span>
<span class="text-xs text-muted tabular-nums">{formatTime(String(event.data.timestamp))}</span>
{/if}
</div>
<p class="text-sm text-dim">{eventSummary(event)}</p>
<p class="text-sm text-dim leading-relaxed">{eventSummary(event)}</p>
{#if event.type === 'SearchPerformed'}
<div class="mt-2">
<PipelineVisualizer
@ -98,3 +144,95 @@
</div>
{/if}
</div>
<style>
/* ── Live connection pill in the header right-slot ──────────────────────── */
.live-status {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.7rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-recall, #34d399);
background: color-mix(in srgb, var(--color-recall, #34d399) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--color-recall, #34d399) 28%, transparent);
white-space: nowrap;
}
.live-status.idle {
color: var(--color-dim, #8b95a5);
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.08);
}
.live-dot {
display: inline-block;
flex-shrink: 0;
}
/* ── Event-count chip ───────────────────────────────────────────────────── */
.count-chip {
display: inline-flex;
align-items: baseline;
gap: 0.35rem;
padding: 0.3rem 0.7rem;
border-radius: 999px;
font-size: 0.78rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.07);
}
/* ── Clear button ───────────────────────────────────────────────────────── */
.clear-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.65rem;
border-radius: 999px;
font-size: 0.75rem;
color: var(--color-muted, #6b7280);
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.06);
transition: color 0.2s ease, background 0.2s ease, border-color 0.2s ease;
}
.clear-btn:hover:not(:disabled) {
color: var(--color-text, #e5e7eb);
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.12);
}
.clear-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ── Event rows ─────────────────────────────────────────────────────────── */
.event-row {
position: relative;
transition:
transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1),
box-shadow 0.28s ease,
background 0.2s ease;
}
.event-row:hover {
background: color-mix(in srgb, var(--evt) 7%, transparent);
}
.event-icon {
width: 1.6rem;
height: 1.6rem;
border-radius: 0.5rem;
}
/* ── Empty state (warm, not a frozen spinner) ───────────────────────────── */
.empty-glyph {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-synapse-glow, #818cf8);
opacity: 0.85;
}
@media not (prefers-reduced-motion: reduce) {
.empty-glyph {
animation: breathe 3.2s ease-in-out infinite;
}
}
</style>

View file

@ -5,6 +5,14 @@
import RetentionCurve from '$components/RetentionCurve.svelte';
import TimeSlider from '$components/TimeSlider.svelte';
import MemoryStateLegend from '$components/MemoryStateLegend.svelte';
// ⚠ MEMORY CINEMA — DO NOT MODIFY. This is the flawless, protected
// AI-narrated flythrough experience. The graph-control overhaul below
// (icons, dropdown, alive empty states) is a SEPARATE concern and must
// never touch the Cinema import, element, or its props.
import MemoryCinema from '$components/MemoryCinema.svelte';
// Graph-control overhaul (2026 alive UI): unique icons + dropdown.
import Icon from '$components/Icon.svelte';
import Dropdown, { type DropdownOption } from '$components/Dropdown.svelte';
import { api } from '$stores/api';
import { eventFeed } from '$stores/websocket';
import { graphState } from '$stores/graph-state.svelte';
@ -20,6 +28,19 @@
let isDreaming = $state(false);
let searchQuery = $state('');
let maxNodes = $state(150);
// Node-count choices for the clear Dropdown control (replaces a native
// <select>). Bound as a string; coerced back to a number on change.
const nodeCountOptions: DropdownOption[] = [
{ value: '50', label: '50 nodes' },
{ value: '100', label: '100 nodes' },
{ value: '150', label: '150 nodes' },
{ value: '200', label: '200 nodes' },
];
let maxNodesChoice = $state('150');
function onMaxNodesChange(v: string) {
maxNodes = parseInt(v, 10);
loadGraph();
}
let temporalEnabled = $state(false);
let temporalDate = $state(new Date());
// Colour spheres by node type, FSRS memory state, or AhaGraph learning tags.
@ -203,14 +224,14 @@
<div class="h-full flex items-center justify-center">
<div class="text-center space-y-4">
<div class="w-16 h-16 mx-auto rounded-full border-2 border-synapse/30 border-t-synapse animate-spin"></div>
<p class="text-dim text-sm">Loading memory graph...</p>
<p class="text-dim text-sm">Weaving your memory graph…</p>
</div>
</div>
{:else if error === 'OFFLINE'}
<div class="h-full flex items-center justify-center">
<div class="text-center space-y-5 max-w-lg px-8">
<div class="text-5xl opacity-40"></div>
<h2 class="text-xl text-bright">MCP Backend Offline</h2>
<div class="text-center space-y-5 max-w-lg px-8 enter">
<div class="mx-auto w-fit text-warning opacity-70 breathe"><Icon name="activation" size={52} strokeWidth={1.2} /></div>
<h2 class="text-xl text-bright text-aurora">MCP Backend Offline</h2>
<p class="text-dim text-sm leading-relaxed">
The Vestige MCP server isn't reachable on <code class="font-mono text-muted">:3927</code>.
The dashboard is running but has nothing to query.
@ -234,17 +255,17 @@ disown</code>
</div>
{:else if error === 'EMPTY'}
<div class="h-full flex items-center justify-center">
<div class="text-center space-y-4 max-w-md px-8">
<div class="text-5xl opacity-30"></div>
<h2 class="text-xl text-bright">Your Mind Awaits</h2>
<p class="text-dim text-sm">No memories yet. Start using Vestige to populate your graph.</p>
<div class="text-center space-y-4 max-w-md px-8 enter">
<div class="mx-auto w-fit text-synapse-glow opacity-50 breathe"><Icon name="graph" size={52} strokeWidth={1.2} /></div>
<h2 class="text-xl text-bright text-aurora">Your Mind Awaits</h2>
<p class="text-dim text-sm">No memories yet — the moment Vestige starts remembering, your constellation will bloom here.</p>
</div>
</div>
{:else if error}
<div class="h-full flex items-center justify-center">
<div class="text-center space-y-4 max-w-md px-8">
<div class="text-5xl opacity-30"></div>
<h2 class="text-xl text-bright">Your Mind Awaits</h2>
<div class="text-center space-y-4 max-w-md px-8 enter">
<div class="mx-auto w-fit text-synapse-glow opacity-50 breathe"><Icon name="graph" size={52} strokeWidth={1.2} /></div>
<h2 class="text-xl text-bright text-aurora">Your Mind Awaits</h2>
<p class="text-dim text-sm">{error}</p>
</div>
</div>
@ -262,34 +283,38 @@ disown</code>
{/if}
<!-- Top controls bar -->
<div class="absolute top-4 left-4 right-4 z-10 flex items-center gap-3">
<div
class="absolute top-0 left-0 right-0 z-10 flex flex-wrap items-center gap-2 px-3 pt-3 sm:top-4 sm:left-4 sm:right-4 sm:gap-3 sm:p-0
[padding-top:max(0.75rem,env(safe-area-inset-top))] [padding-left:max(0.75rem,env(safe-area-inset-left))] [padding-right:max(0.75rem,env(safe-area-inset-right))]
sm:[padding-top:0] sm:[padding-left:0] sm:[padding-right:0]"
>
<!-- Search -->
<div class="flex gap-2 flex-1 max-w-md">
<div class="flex gap-2 w-full sm:flex-1 sm:w-auto sm:max-w-md">
<input
type="text"
placeholder="Center graph on..."
bind:value={searchQuery}
onkeydown={(e) => e.key === 'Enter' && searchGraph()}
class="flex-1 px-3 py-2 glass rounded-xl text-text text-sm
class="flex-1 min-w-0 px-3 py-2 glass rounded-xl text-text text-sm
placeholder:text-muted focus:outline-none focus:!border-synapse/40 transition"
/>
<button onclick={searchGraph}
class="px-3 py-2 bg-synapse/20 border border-synapse/40 text-synapse-glow text-sm rounded-xl hover:bg-synapse/30 transition backdrop-blur-sm">
class="shrink-0 min-h-10 px-3 py-2 bg-synapse/20 border border-synapse/40 text-synapse-glow text-sm rounded-xl hover:bg-synapse/30 transition backdrop-blur-sm">
Focus
</button>
</div>
<div class="flex gap-2 ml-auto">
<div class="flex flex-wrap gap-2 w-full sm:w-auto sm:ml-auto">
<!-- v2.0.8: colour mode toggle. Switches sphere tint between node type
(fact / concept / event / …) and FSRS memory state (active / dormant /
silent / unavailable). Legend auto-renders in state mode. -->
<div class="flex glass rounded-xl p-0.5 text-xs" role="radiogroup" aria-label="Colour mode">
<div class="flex shrink min-w-0 glass rounded-xl p-0.5 text-xs" role="radiogroup" aria-label="Colour mode">
<button
type="button"
role="radio"
aria-checked={colorMode === 'type'}
onclick={() => (colorMode = 'type')}
class="px-3 py-1.5 rounded-lg transition {colorMode === 'type' ? 'bg-synapse/25 text-synapse-glow' : 'text-dim hover:text-text'}"
class="min-h-9 px-3 py-1.5 rounded-lg transition {colorMode === 'type' ? 'bg-synapse/25 text-synapse-glow' : 'text-dim hover:text-text'}"
title="Colour by node type (fact, concept, event, …)"
>
Type
@ -299,7 +324,7 @@ disown</code>
role="radio"
aria-checked={colorMode === 'state'}
onclick={() => (colorMode = 'state')}
class="px-3 py-1.5 rounded-lg transition {colorMode === 'state' ? 'bg-synapse/25 text-synapse-glow' : 'text-dim hover:text-text'}"
class="min-h-9 px-3 py-1.5 rounded-lg transition {colorMode === 'state' ? 'bg-synapse/25 text-synapse-glow' : 'text-dim hover:text-text'}"
title="Colour by FSRS memory state (active / dormant / silent / unavailable)"
>
State
@ -309,26 +334,26 @@ disown</code>
role="radio"
aria-checked={colorMode === 'ahagraph'}
onclick={() => (colorMode = 'ahagraph')}
class="px-3 py-1.5 rounded-lg transition {colorMode === 'ahagraph' ? 'bg-synapse/25 text-synapse-glow' : 'text-dim hover:text-text'}"
class="min-h-9 px-3 py-1.5 rounded-lg transition {colorMode === 'ahagraph' ? 'bg-synapse/25 text-synapse-glow' : 'text-dim hover:text-text'}"
title="Colour by AhaGraph tags (aha / confusion / failure)"
>
AhaGraph
</button>
</div>
<!-- Node count -->
<select bind:value={maxNodes} onchange={() => loadGraph()}
class="px-2 py-2 glass rounded-xl text-dim text-xs">
<option value={50}>50 nodes</option>
<option value={100}>100 nodes</option>
<option value={150}>150 nodes</option>
<option value={200}>200 nodes</option>
</select>
<!-- Node count — clear animated Dropdown (replaces native <select>) -->
<Dropdown
options={nodeCountOptions}
bind:value={maxNodesChoice}
icon="graph"
class="shrink-0"
onChange={onMaxNodesChange}
/>
<!-- Brightness slider (persists in localStorage). Scales node emissive,
glow, and distance-compensated fog falloff. Default 1.0, range 0.5-2.5. -->
<label
class="flex items-center gap-2 px-3 py-2 glass rounded-xl text-dim text-xs select-none"
class="flex shrink-0 items-center gap-2 min-h-10 px-3 py-2 glass rounded-xl text-dim text-xs select-none"
title="Adjust graph brightness ({graphState.brightness.toFixed(1)}x). Combines with auto distance compensation."
>
<span class="text-synapse-glow"></span>
@ -350,23 +375,41 @@ disown</code>
<button
onclick={triggerDream}
disabled={isDreaming}
class="px-4 py-2 rounded-xl bg-dream/20 border border-dream/40 text-dream-glow text-sm
class="shrink-0 inline-flex items-center gap-2 min-h-10 px-4 py-2 rounded-xl bg-dream/20 border border-dream/40 text-dream-glow text-sm
hover:bg-dream/30 transition-all backdrop-blur-sm disabled:opacity-50
{isDreaming ? 'glow-dream animate-pulse-glow' : ''}"
>
{isDreaming ? '◈ Dreaming...' : '◈ Dream'}
<span class={isDreaming ? 'breathe' : ''}><Icon name="dreams" size={16} /></span>
{isDreaming ? 'Dreaming…' : 'Dream'}
</button>
<!-- ═══════════════════════════════════════════════════════════
MEMORY CINEMA — PROTECTED · DO NOT MODIFY
The AI-narrated flythrough. Its trigger + props are flawless
and intentionally untouched by the graph-control overhaul.
═══════════════════════════════════════════════════════════ -->
{#if displayNodes.length > 0}
<MemoryCinema
nodes={displayNodes}
edges={displayEdges}
centerId={graphData?.center_id ?? ''}
/>
{/if}
<!-- ═══════════ END PROTECTED MEMORY CINEMA ═══════════ -->
<!-- Reload -->
<button onclick={() => loadGraph()}
class="px-3 py-2 glass rounded-xl text-dim text-sm hover:text-text transition">
class="shrink-0 min-h-10 min-w-10 inline-flex items-center justify-center px-3 py-2 glass rounded-xl text-dim hover:text-text hover:rotate-180 transition-all duration-500"
title="Reload graph"
aria-label="Reload graph"
>
<Icon name="pulse" size={16} />
</button>
</div>
</div>
<!-- Bottom stats -->
<div class="absolute bottom-4 left-4 z-10 text-xs text-dim glass rounded-xl px-3 py-2">
<div class="graph-stats-pill absolute bottom-4 left-4 z-10 text-xs text-dim glass rounded-xl px-3 py-2">
{#if graphData}
<span>{liveNodeCount} nodes</span>
<span class="mx-2 text-subtle">·</span>
@ -487,9 +530,9 @@ disown</code>
<!-- Explore from this node -->
<a
href="{base}/explore"
class="block text-center px-3 py-2 rounded-xl bg-dream/10 text-dream-glow text-xs hover:bg-dream/20 transition border border-dream/20"
class="flex items-center justify-center gap-2 px-3 py-2 rounded-xl bg-dream/10 text-dream-glow text-xs hover:bg-dream/20 transition border border-dream/20"
>
Explore Connections
<Icon name="explore" size={14} /> Explore Connections
</a>
</div>
</div>

View file

@ -6,6 +6,11 @@
import type { ImportanceScore, Memory } from '$types';
import { NODE_TYPE_COLORS } from '$types';
import ImportanceRadar from '$components/ImportanceRadar.svelte';
import PageHeader from '$components/PageHeader.svelte';
import AnimatedNumber from '$components/AnimatedNumber.svelte';
import Icon from '$components/Icon.svelte';
import { reveal } from '$lib/actions/reveal';
import { spotlight } from '$lib/actions/interactions';
// ── Section 1: Test Importance ───────────────────────────────────────────
let content = $state('');
@ -145,17 +150,15 @@
</script>
<div class="p-6 max-w-5xl mx-auto space-y-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl text-bright font-semibold">Importance Radar</h1>
<p class="text-sm text-dim mt-1">
4-channel importance model: Novelty · Arousal · Reward · Attention
</p>
</div>
</div>
<PageHeader
icon="importance"
title="Importance Radar"
subtitle="4-channel importance model: Novelty · Arousal · Reward · Attention"
accent="warning"
/>
<!-- ── Section 1: Test Importance ─────────────────────────────────────── -->
<section class="glass-panel rounded-2xl p-6 space-y-5">
<section use:reveal class="glass-panel rounded-2xl p-6 space-y-5">
<div>
<h2 class="text-sm font-semibold text-bright uppercase tracking-wider">Test Importance</h2>
<p class="text-xs text-muted mt-1">
@ -191,12 +194,15 @@
</div>
<!-- Radar + composite readout -->
<div class="flex flex-col items-center gap-4 md:min-w-[340px]">
<div
use:spotlight
class="spotlight-surface flex flex-col items-center gap-4 md:min-w-[340px] rounded-2xl"
>
{#if score}
<div class="text-center">
<div class="relative z-[1] text-center enter">
<div class="text-[10px] uppercase tracking-widest text-muted">Composite</div>
<div class="text-5xl font-semibold text-bright leading-none mt-1">
{(score.composite * 100).toFixed(0)}<span class="text-xl text-dim">%</span>
<div class="text-5xl font-semibold text-aurora leading-none mt-1 tabular-nums">
<AnimatedNumber value={score.composite} scale={100} decimals={0} suffix="%" />
</div>
</div>
{#key radarKey}
@ -211,8 +217,11 @@
<!-- Recommendation -->
{#if score.composite > 0.6}
<div class="w-full text-center space-y-1">
<div class="text-lg font-semibold text-recall">✓ Save</div>
<div class="relative z-[1] w-full text-center space-y-1 enter">
<div class="flex items-center justify-center gap-1.5 text-lg font-semibold text-recall">
<Icon name="sparkle" size={18} />
<span>Save</span>
</div>
<p class="text-xs text-dim leading-relaxed">
Composite {(score.composite * 100).toFixed(0)}% &gt; 60% threshold.
{#if topChannel}
@ -221,8 +230,11 @@
</p>
</div>
{:else}
<div class="w-full text-center space-y-1">
<div class="text-lg font-semibold text-decay"> Skip</div>
<div class="relative z-[1] w-full text-center space-y-1 enter">
<div class="flex items-center justify-center gap-1.5 text-lg font-semibold text-decay">
<Icon name="close" size={18} />
<span>Skip</span>
</div>
<p class="text-xs text-dim leading-relaxed">
Composite {(score.composite * 100).toFixed(0)}% &lt; 60% threshold.
{#if weakestChannel}
@ -232,8 +244,10 @@
</div>
{/if}
{:else}
<div class="flex flex-col items-center justify-center min-h-[320px] w-full text-center px-4">
<div class="text-3xl text-muted mb-3"></div>
<div class="relative z-[1] flex flex-col items-center justify-center min-h-[320px] w-full text-center px-4">
<div class="text-warning/70 mb-3 breathe">
<Icon name="importance" size={36} />
</div>
<p class="text-sm text-dim">Type some content above to score its importance.</p>
<p class="text-xs text-muted mt-2 max-w-xs">
Composite = 0.25·novelty + 0.30·arousal + 0.25·reward + 0.20·attention.
@ -246,7 +260,7 @@
</section>
<!-- ── Section 2: Top Important Memories This Week ────────────────────── -->
<section class="space-y-4">
<section use:reveal class="space-y-4">
<div class="flex items-end justify-between">
<div>
<h2 class="text-sm font-semibold text-bright uppercase tracking-wider">
@ -267,21 +281,29 @@
{#if loadingMemories}
<div class="grid gap-3 md:grid-cols-2">
{#each Array(6) as _}
<div class="h-28 glass-subtle rounded-xl animate-pulse"></div>
<div class="h-28 glass-subtle rounded-xl shimmer"></div>
{/each}
</div>
{:else if memories.length === 0}
<div class="text-center py-12 text-dim">
<p class="text-sm">No memories yet.</p>
<div class="flex flex-col items-center justify-center text-center py-16 px-6 glass-subtle rounded-2xl enter">
<div class="text-warning/60 mb-3 breathe">
<Icon name="importance" size={40} />
</div>
<p class="text-sm text-bright font-medium">No standout memories yet</p>
<p class="text-xs text-muted mt-1.5 max-w-sm">
As you capture decisions, wins, and discoveries, the most important ones
will rise to the top here — ranked by retention, reviews, and recency.
</p>
</div>
{:else}
<div class="grid gap-3 md:grid-cols-2">
{#each memories as memory (memory.id)}
{#each memories as memory, i (memory.id)}
{@const ch = perMemoryScores[memory.id]}
<button
type="button"
use:reveal={{ delay: i * 45 }}
onclick={() => openMemory(memory.id)}
class="text-left p-4 glass-subtle rounded-xl hover:bg-white/[0.04] hover:border-synapse/30
class="lift text-left p-4 glass-subtle rounded-xl hover:bg-white/[0.04] hover:border-synapse/30
transition-all duration-200 flex items-start gap-4"
>
<div class="flex-1 min-w-0 space-y-2">

View file

@ -2,6 +2,12 @@
import { onMount } from 'svelte';
import { api } from '$stores/api';
import type { IntentionItem } from '$types';
import PageHeader from '$lib/components/PageHeader.svelte';
import Dropdown, { type DropdownOption } from '$lib/components/Dropdown.svelte';
import Icon from '$lib/components/Icon.svelte';
import AnimatedNumber from '$lib/components/AnimatedNumber.svelte';
import { reveal } from '$lib/actions/reveal';
import { spotlight } from '$lib/actions/interactions';
let intentions: IntentionItem[] = $state([]);
let predictions: Record<string, unknown>[] = $state([]);
@ -29,13 +35,25 @@
1: 'text-muted',
};
const TRIGGER_ICONS: Record<string, string> = {
time: '⏰',
context: '◎',
event: '⚡',
manual: '◇',
// Each trigger kind gets a drawn-on Icon instead of a Unicode glyph, so the
// list reads as part of the same premium icon system as the rest of the app.
const TRIGGER_ICONS: Record<string, 'schedule' | 'graph' | 'pulse' | 'intentions'> = {
time: 'schedule',
context: 'graph',
event: 'pulse',
manual: 'intentions',
};
// Clear, labelled dropdown options replace the row of status tabs. Same values,
// same filtering behavior — just clearer at a glance.
const statusOptions: DropdownOption[] = [
{ value: 'active', label: 'Active', color: '#6366f1' },
{ value: 'fulfilled', label: 'Fulfilled', color: '#10b981' },
{ value: 'snoozed', label: 'Snoozed', color: '#a78bfa' },
{ value: 'cancelled', label: 'Cancelled', color: '#8B95A5' },
{ value: 'all', label: 'All', color: '#00D4FF' },
];
function summarizeTrigger(intention: IntentionItem): string {
// The API returns trigger_data as a JSON-encoded string. Parse it, pick the
// most human-readable field, then truncate for display.
@ -99,56 +117,64 @@
</script>
<div class="p-6 max-w-5xl mx-auto space-y-8">
<div class="flex items-center justify-between">
<h1 class="text-xl text-bright font-semibold">Intentions & Predictions</h1>
<span class="text-xs text-muted">{intentions.length} intentions</span>
</div>
<PageHeader
icon="intentions"
title="Intentions & Predictions"
subtitle="Prospective memory and the needs Vestige sees coming"
accent="memory"
>
<span class="text-dim text-sm tabular-nums">
<AnimatedNumber value={intentions.length} /> intentions
</span>
</PageHeader>
<!-- Intentions Section -->
<div class="space-y-4">
<div class="flex items-center gap-2">
<h2 class="text-sm text-bright font-semibold">Prospective Memory</h2>
<span class="text-xs text-muted">"Remember to do X when Y happens"</span>
</div>
<div class="space-y-4 enter">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<h2 class="text-sm text-bright font-semibold">Prospective Memory</h2>
<span class="text-xs text-muted">"Remember to do X when Y happens"</span>
</div>
<!-- Status filter tabs -->
<div class="flex gap-1.5">
{#each ['active', 'fulfilled', 'snoozed', 'cancelled', 'all'] as status}
<button
onclick={() => changeFilter(status)}
class="px-3 py-1.5 rounded-xl text-xs transition {statusFilter === status
? 'bg-synapse/20 text-synapse-glow border border-synapse/40'
: 'glass-subtle text-dim hover:bg-white/[0.03]'}"
>
{status.charAt(0).toUpperCase() + status.slice(1)}
</button>
{/each}
<!-- Status filter dropdown (same values & logic as the old tabs) -->
<Dropdown
options={statusOptions}
value={statusFilter}
label="Status"
icon="filter"
onChange={changeFilter}
/>
</div>
{#if loading}
<div class="space-y-2">
{#each Array(4) as _}
<div class="h-16 glass-subtle rounded-xl animate-pulse"></div>
<div class="h-16 glass-subtle rounded-xl shimmer"></div>
{/each}
</div>
{:else if intentions.length === 0}
<div class="text-center py-12 text-dim">
<div class="text-4xl mb-3 opacity-20"></div>
<p>No {statusFilter === 'all' ? '' : statusFilter + ' '}intentions.</p>
<p class="text-xs text-muted mt-1">Use "Remind me..." in conversation to create intentions.</p>
<div class="enter flex flex-col items-center justify-center text-center py-14 gap-4">
<div class="text-dim opacity-60 breathe"><Icon name="intentions" size={44} strokeWidth={1.2} /></div>
<p class="text-dim text-sm max-w-sm">
No {statusFilter === 'all' ? '' : statusFilter + ' '}intentions yet — say "Remind me…" in conversation and Vestige will hold the thought for you.
</p>
</div>
{:else}
<div class="space-y-2">
{#each intentions as intention}
<div class="p-4 glass-subtle rounded-xl">
<div class="flex items-start gap-3">
{#each intentions as intention, i (intention.id)}
<div
use:reveal={{ delay: Math.min(i * 35, 350), y: 12 }}
use:spotlight
class="spotlight-surface p-4 glass-subtle rounded-xl lift transition-all duration-200"
>
<div class="relative z-[1] flex items-start gap-3">
<!-- Trigger icon -->
<div class="w-8 h-8 rounded-lg bg-white/[0.04] flex items-center justify-center text-lg flex-shrink-0">
{TRIGGER_ICONS[intention.trigger_type] || '◇'}
<div class="w-9 h-9 rounded-lg bg-white/[0.04] border border-synapse/10 flex items-center justify-center text-synapse-glow flex-shrink-0">
<Icon name={TRIGGER_ICONS[intention.trigger_type] || 'intentions'} size={18} />
</div>
<div class="flex-1 min-w-0">
<p class="text-sm text-text">{intention.content}</p>
<p class="text-sm text-text leading-relaxed">{intention.content}</p>
<div class="flex flex-wrap gap-2 mt-2">
<!-- Status badge -->
<span class="px-2 py-0.5 text-[10px] rounded-lg border {STATUS_COLORS[intention.status] || 'text-dim bg-white/[0.03] border-subtle/20'}">
@ -175,7 +201,7 @@
</div>
</div>
<span class="text-[10px] text-muted flex-shrink-0">{formatDate(intention.created_at)}</span>
<span class="text-[10px] text-muted tabular-nums flex-shrink-0">{formatDate(intention.created_at)}</span>
</div>
</div>
{/each}
@ -184,34 +210,49 @@
</div>
<!-- Predictions Section -->
<div class="pt-6 border-t border-synapse/10 space-y-4">
<div class="pt-6 border-t border-synapse/10 space-y-4 enter">
<div class="flex items-center gap-2">
<span class="text-dream-glow"><Icon name="sparkle" size={16} /></span>
<h2 class="text-sm text-bright font-semibold">Predicted Needs</h2>
<span class="text-xs text-muted">What you might need next</span>
</div>
{#if predictions.length === 0}
<div class="text-center py-8 text-dim">
<div class="text-3xl mb-3 opacity-20"></div>
<p class="text-sm">No predictions yet. Use Vestige more to train the predictive model.</p>
{#if loading}
<div class="space-y-2">
{#each Array(3) as _}
<div class="h-14 glass-subtle rounded-xl shimmer"></div>
{/each}
</div>
{:else if predictions.length === 0}
<div class="enter flex flex-col items-center justify-center text-center py-10 gap-4">
<div class="text-dim opacity-60 breathe"><Icon name="sparkle" size={40} strokeWidth={1.2} /></div>
<p class="text-dim text-sm max-w-sm">
No predictions yet — keep using Vestige and the predictive model will start surfacing what you'll reach for next.
</p>
</div>
{:else}
<div class="space-y-2">
{#each predictions as pred, i}
<div class="p-3 glass-subtle rounded-xl flex items-start gap-3">
<div class="w-6 h-6 rounded-full bg-dream/20 text-dream-glow text-xs flex items-center justify-center flex-shrink-0 mt-0.5">
{i + 1}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm text-text line-clamp-2">{pred.content}</p>
<div class="flex gap-3 mt-1 text-xs text-muted">
<span>{pred.nodeType}</span>
{#if pred.retention}
<span>{(Number(pred.retention) * 100).toFixed(0)}% retention</span>
{/if}
{#if pred.predictedNeed}
<span class="text-dream-glow">{pred.predictedNeed} need</span>
{/if}
<div
use:reveal={{ delay: Math.min(i * 35, 350), y: 12 }}
use:spotlight
class="spotlight-surface p-3 glass-subtle rounded-xl lift transition-all duration-200"
>
<div class="relative z-[1] flex items-start gap-3">
<div class="w-6 h-6 rounded-full bg-dream/20 text-dream-glow text-xs tabular-nums flex items-center justify-center flex-shrink-0 mt-0.5">
{i + 1}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm text-text line-clamp-2">{pred.content}</p>
<div class="flex gap-3 mt-1 text-xs text-muted">
<span>{pred.nodeType}</span>
{#if pred.retention}
<span class="tabular-nums">{(Number(pred.retention) * 100).toFixed(0)}% retention</span>
{/if}
{#if pred.predictedNeed}
<span class="text-dream-glow">{pred.predictedNeed} need</span>
{/if}
</div>
</div>
</div>
</div>

View file

@ -4,6 +4,12 @@
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';
let memories: Memory[] = $state([]);
let searchQuery = $state('');
@ -46,65 +52,111 @@
if (r > 0.4) return '#f59e0b';
return '#ef4444';
}
// 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 },
];
// 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();
}
</script>
<div class="p-6 max-w-6xl mx-auto space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-xl text-bright font-semibold">Memories</h1>
<span class="text-dim text-sm">{memories.length} results</span>
</div>
<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>
<!-- Search & Filters -->
<div class="flex gap-3 flex-wrap">
<input
type="text"
placeholder="Search memories..."
bind:value={searchQuery}
oninput={onSearch}
class="flex-1 min-w-64 px-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-1 focus:ring-synapse/20 transition backdrop-blur-sm"
/>
<select bind:value={selectedType} onchange={loadMemories}
class="px-3 py-2.5 bg-white/[0.03] border border-synapse/10 rounded-xl text-dim text-sm focus:outline-none backdrop-blur-sm">
<option value="">All types</option>
<option value="fact">Fact</option>
<option value="concept">Concept</option>
<option value="event">Event</option>
<option value="person">Person</option>
<option value="place">Place</option>
<option value="note">Note</option>
<option value="pattern">Pattern</option>
<option value="decision">Decision</option>
</select>
<div class="flex items-center gap-2 text-xs text-dim">
<span>Min retention:</span>
<input type="range" min="0" max="1" step="0.1" bind:value={minRetention} onchange={loadMemories}
class="w-24 accent-synapse" />
<span>{(minRetention * 100).toFixed(0)}%</span>
<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 animate-pulse"></div>
<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 (memory.id)}
{#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="text-left p-4 glass-subtle rounded-xl hover:bg-white/[0.04]
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' : ''}"
{selectedMemory?.id === memory.id ? '!border-synapse/40 glow-synapse live-border' : ''}"
>
<div class="flex items-start justify-between gap-4">
<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'}"></span>
<span class="text-xs text-dim">{memory.nodeType}</span>
<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}
@ -121,7 +173,7 @@
{#if selectedMemory?.id === memory.id}
{@const activeTab = expandedTab[memory.id] ?? 'content'}
<div class="mt-4 pt-4 border-t border-synapse/10 space-y-3">
<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

View file

@ -11,6 +11,10 @@
<script lang="ts">
import { onMount } from 'svelte';
import PatternTransferHeatmap from '$components/PatternTransferHeatmap.svelte';
import PageHeader from '$components/PageHeader.svelte';
import Icon from '$components/Icon.svelte';
import AnimatedNumber from '$components/AnimatedNumber.svelte';
import { reveal } from '$lib/actions/reveal';
type Category =
| 'ErrorHandling'
@ -392,35 +396,53 @@
</script>
<div class="relative mx-auto max-w-7xl space-y-6 p-6">
<!-- Header -->
<header class="space-y-2">
<h1 class="text-xl font-semibold text-bright">Cross-Project Intelligence</h1>
<p class="text-sm text-dim">Patterns learned here, applied there.</p>
</header>
<PageHeader
icon="patterns"
title="Cross-Project Intelligence"
subtitle="Patterns learned here, applied there — across every tracked project."
accent="dream"
>
{#if !loading && !error}
<span class="glass-subtle rounded-full px-3 py-1 text-xs text-dim tabular-nums">
<span class="font-semibold text-bright"><AnimatedNumber value={patternCount} /></span>
pattern{patternCount === 1 ? '' : 's'}
·
<span class="font-semibold text-bright"><AnimatedNumber value={totalTransfers} /></span>
transfer{totalTransfers === 1 ? '' : 's'}
</span>
{/if}
</PageHeader>
<!-- Category tabs -->
<div class="glass-panel flex flex-wrap items-center gap-1.5 rounded-2xl p-2">
<!-- Category tabs — switching category clears the selected heatmap cell. -->
<div class="glass-panel flex flex-wrap items-center gap-1.5 rounded-2xl p-2 enter">
<button
type="button"
onclick={() => selectCategory('All')}
class="rounded-lg px-3 py-1.5 text-xs font-medium transition {activeCategory === 'All'
? 'bg-synapse/25 text-synapse-glow'
class="lift rounded-lg px-3 py-1.5 text-xs font-medium transition {activeCategory === 'All'
? 'bg-synapse/25 text-synapse-glow shadow-[0_0_16px_-4px_var(--color-synapse-glow)]'
: 'text-dim hover:bg-white/[0.04] hover:text-text'}"
>
All
<span class="inline-flex items-center gap-1.5">
<Icon name="sparkle" size={13} />
All
</span>
</button>
{#each CATEGORIES as cat (cat)}
<button
type="button"
onclick={() => selectCategory(cat)}
class="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition {activeCategory ===
class="lift flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition {activeCategory ===
cat
? 'bg-synapse/25 text-synapse-glow'
? 'bg-synapse/25 text-synapse-glow shadow-[0_0_16px_-4px_var(--color-synapse-glow)]'
: 'text-dim hover:bg-white/[0.04] hover:text-text'}"
>
<span
class="h-1.5 w-1.5 rounded-full"
style="background: {CATEGORY_COLORS[cat]}"
class="h-1.5 w-1.5 rounded-full transition-all duration-300 {activeCategory === cat
? 'scale-150'
: ''}"
style="background: {CATEGORY_COLORS[cat]}; {activeCategory === cat
? `box-shadow: 0 0 8px ${CATEGORY_COLORS[cat]};`
: ''}"
></span>
{cat}
</button>
@ -428,27 +450,29 @@
</div>
{#if error}
<div class="glass-panel flex flex-col items-center gap-3 rounded-2xl p-10 text-center">
<div class="glass-panel enter flex flex-col items-center gap-3 rounded-2xl p-10 text-center">
<div class="text-decay/80"><Icon name="contradictions" size={28} /></div>
<div class="text-sm text-decay">Couldn't load pattern transfers</div>
<div class="max-w-md text-xs text-muted">{error}</div>
<button
type="button"
onclick={load}
class="mt-2 rounded-lg bg-synapse/20 px-4 py-2 text-xs font-medium text-synapse-glow transition hover:bg-synapse/30"
class="lift mt-2 inline-flex items-center gap-1.5 rounded-lg bg-synapse/20 px-4 py-2 text-xs font-medium text-synapse-glow transition hover:bg-synapse/30"
>
<Icon name="pulse" size={14} />
Retry
</button>
</div>
{:else if loading}
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
<div class="glass-subtle h-[520px] animate-pulse rounded-2xl"></div>
<div class="glass-subtle h-[520px] animate-pulse rounded-2xl"></div>
<div class="glass-subtle shimmer h-[520px] rounded-2xl"></div>
<div class="glass-subtle shimmer h-[520px] rounded-2xl"></div>
</div>
{:else}
<!-- Main grid: heatmap (70%) + sidebar -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
<!-- Heatmap column -->
<div class="space-y-4">
<div class="space-y-4 enter">
<PatternTransferHeatmap
projects={data.projects}
patterns={categoryFiltered}
@ -458,9 +482,10 @@
{#if selectedCell}
<div
class="glass-subtle flex items-center justify-between rounded-xl px-4 py-2.5 text-xs"
class="glass-subtle enter flex items-center justify-between rounded-xl px-4 py-2.5 text-xs"
>
<div class="flex items-center gap-2">
<span class="text-synapse-glow"><Icon name="filter" size={13} /></span>
<span class="text-muted">Filtered to</span>
<span class="font-mono text-bright">{selectedCell.from}</span>
<span class="text-synapse-glow"></span>
@ -469,8 +494,9 @@
<button
type="button"
onclick={clearCellFilter}
class="rounded-md bg-white/[0.04] px-2 py-1 text-dim transition hover:bg-white/[0.08] hover:text-text"
class="inline-flex items-center gap-1 rounded-md bg-white/[0.04] px-2 py-1 text-dim transition hover:bg-white/[0.08] hover:text-text"
>
<Icon name="close" size={12} />
Clear
</button>
</div>
@ -478,29 +504,34 @@
</div>
<!-- Sidebar: Top Transferred Patterns -->
<aside class="glass-panel flex flex-col rounded-2xl p-4">
<aside class="glass-panel enter flex flex-col rounded-2xl p-4">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold text-bright">Top Transferred Patterns</h2>
<span class="text-[11px] text-muted">
{sidebarPatterns.length}
<h2 class="flex items-center gap-2 text-sm font-semibold text-bright">
<span class="text-dream-glow"><Icon name="patterns" size={15} /></span>
Top Transferred Patterns
</h2>
<span class="text-[11px] text-muted tabular-nums">
<AnimatedNumber value={sidebarPatterns.length} />
{sidebarPatterns.length === 1 ? 'pattern' : 'patterns'}
</span>
</div>
{#if sidebarPatterns.length === 0}
<div class="flex flex-1 flex-col items-center justify-center gap-2 py-10 text-center">
<div class="flex flex-1 flex-col items-center justify-center gap-3 py-12 text-center">
<div class="breathe text-dim/70"><Icon name="explore" size={32} /></div>
<div class="text-xs font-medium text-dim">No matching patterns</div>
<div class="max-w-[220px] text-[11px] text-muted">
{selectedCell
? 'No patterns transferred from this origin to this destination.'
: 'No patterns in this category.'}
? 'No patterns transferred from this origin to this destination yet — try another cell or clear the filter.'
: 'Nothing in this category yet. Pick another category to explore what travels between projects.'}
</div>
</div>
{:else}
<ul class="flex-1 space-y-2 overflow-y-auto pr-1" style="max-height: 560px;">
{#each sidebarPatterns as p (p.name)}
{#each sidebarPatterns as p, i (p.name)}
<li
class="rounded-lg border border-synapse/5 bg-white/[0.02] p-3 transition hover:border-synapse/20 hover:bg-white/[0.04]"
use:reveal={{ y: 12, delay: Math.min(i * 45, 360) }}
class="lift rounded-lg border border-synapse/5 bg-white/[0.02] p-3 transition hover:border-synapse/20 hover:bg-white/[0.04]"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1 space-y-1.5">
@ -531,11 +562,11 @@
</div>
<div class="flex flex-shrink-0 flex-col items-end gap-1">
<span
class="rounded-full bg-synapse/15 px-2 py-0.5 text-xs font-semibold text-synapse-glow"
class="rounded-full bg-synapse/15 px-2 py-0.5 text-xs font-semibold text-synapse-glow tabular-nums"
>
{p.transfer_count}
<AnimatedNumber value={p.transfer_count} />
</span>
<span class="text-[10px] text-muted">
<span class="text-[10px] text-muted tabular-nums">
{(p.confidence * 100).toFixed(0)}%
</span>
</div>
@ -549,17 +580,23 @@
<!-- Stats footer -->
<footer
class="glass-subtle flex flex-wrap items-center justify-between gap-3 rounded-xl px-4 py-3 text-xs text-dim"
class="glass-subtle enter flex flex-wrap items-center justify-between gap-3 rounded-xl px-4 py-3 text-xs text-dim"
>
<div>
<span class="font-semibold text-bright">{patternCount}</span>
<div class="tabular-nums">
<span class="font-semibold text-bright"><AnimatedNumber value={patternCount} /></span>
pattern{patternCount === 1 ? '' : 's'} across
<span class="font-semibold text-bright">{projectCount}</span>
<span class="font-semibold text-bright"><AnimatedNumber value={projectCount} /></span>
project{projectCount === 1 ? '' : 's'},
<span class="font-semibold text-bright">{totalTransfers}</span>
<span class="font-semibold text-bright"><AnimatedNumber value={totalTransfers} /></span>
total transfer{totalTransfers === 1 ? '' : 's'}
</div>
<div class="text-muted">
<div class="inline-flex items-center gap-1.5 text-muted">
<span
class="h-1.5 w-1.5 rounded-full"
style="background: {activeCategory === 'All'
? 'var(--color-synapse-glow)'
: CATEGORY_COLORS[activeCategory]}"
></span>
{activeCategory === 'All' ? 'All categories' : activeCategory}
</div>
</footer>

View file

@ -3,6 +3,11 @@
import { api } from '$stores/api';
import ReasoningChain from '$components/ReasoningChain.svelte';
import EvidenceCard from '$components/EvidenceCard.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Icon from '$lib/components/Icon.svelte';
import AnimatedNumber from '$lib/components/AnimatedNumber.svelte';
import { reveal } from '$lib/actions/reveal';
import { spotlight, magnetic } from '$lib/actions/interactions';
import {
confidenceColor,
confidenceLabel,
@ -244,40 +249,39 @@
<title>Reasoning Theater · Vestige</title>
</svelte:head>
<div class="p-6 max-w-6xl mx-auto space-y-8">
<div class="p-6 max-w-6xl mx-auto space-y-8 enter">
<!-- Header -->
<div class="space-y-2">
<div class="flex items-center gap-3">
<span class="text-2xl text-dream-glow"></span>
<h1 class="text-xl text-bright font-semibold">Reasoning Theater</h1>
<span class="px-2 py-0.5 rounded bg-dream/15 border border-dream/30 text-[10px] text-dream-glow uppercase tracking-wider">
deep_reference
</span>
</div>
<p class="text-xs text-dim max-w-2xl">
Watch Vestige reason. Your query runs the 8-stage cognitive pipeline — broad retrieval,
spreading activation, FSRS trust scoring, intent classification, supersession, contradiction
analysis, relation assessment, template reasoning — and returns a pre-built answer with
trust-scored evidence.
</p>
</div>
<PageHeader
icon="reasoning"
title="Reasoning Theater"
subtitle="Watch Vestige reason — the 8-stage cognitive pipeline runs locally and returns a pre-built answer with trust-scored evidence."
accent="dream"
>
<span
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-dream/15 border border-dream/30 text-[10px] text-dream-glow uppercase tracking-wider font-mono"
>
<span class="ping-host w-1.5 h-1.5 rounded-full" style="color: var(--color-dream); background: var(--color-dream)"></span>
deep_reference
</span>
</PageHeader>
<!-- Cmd+K Ask Palette -->
<div class="glass-panel rounded-2xl p-5 space-y-4">
<div class="flex items-center gap-3">
<span class="text-lg text-synapse-glow"></span>
<span class="text-synapse-glow {loading ? 'breathe' : ''}"><Icon name="reasoning" size={20} /></span>
<input
bind:this={askInputEl}
type="text"
bind:value={query}
onkeydown={(e) => e.key === 'Enter' && ask()}
placeholder="Ask your memory anything..."
placeholder="Ask your memory anything"
class="flex-1 bg-transparent text-bright text-lg placeholder:text-muted focus:outline-none font-mono"
/>
<kbd class="hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded bg-white/[0.04] border border-synapse/15 text-[10px] text-dim font-mono">
<span></span>K
</kbd>
<button
use:magnetic
onclick={ask}
disabled={!query.trim() || loading}
class="px-4 py-2 rounded-xl bg-synapse/20 border border-synapse/40 text-synapse-glow text-sm hover:bg-synapse/30 transition disabled:opacity-40 disabled:cursor-not-allowed"
@ -330,24 +334,25 @@
<!-- REASONING CHAIN (hero — this IS the answer) -->
{#if response.reasoning}
<div class="space-y-3">
<div class="space-y-3" use:reveal>
<div class="flex items-center justify-between">
<h2 class="text-sm text-bright font-semibold flex items-center gap-2">
<span class="text-dream-glow"></span>
<span class="text-dream-glow"><Icon name="reasoning" size={16} /></span>
Reasoning
</h2>
<div class="flex items-center gap-3 text-[10px] text-muted font-mono">
<span>intent: <span class="text-dim">{response.intent}</span></span>
<span>·</span>
<span>{response.memoriesAnalyzed} analyzed</span>
<span><AnimatedNumber value={response.memoriesAnalyzed} /> analyzed</span>
<span>·</span>
<span style="color: {confColor}">{conf}% {confidenceLabel(conf)}</span>
</div>
</div>
<div
class="glass-panel rounded-2xl p-6 font-mono text-sm text-bright whitespace-pre-wrap leading-relaxed"
use:spotlight
class="spotlight-surface glass-panel rounded-2xl p-6 font-mono text-sm text-bright whitespace-pre-wrap leading-relaxed"
style="box-shadow: inset 0 1px 0 0 rgba(255,255,255,0.03), 0 0 32px {confColor}20, 0 8px 32px rgba(0,0,0,0.4); border-color: {confColor}35;"
>{response.reasoning}</div>
><span class="relative z-[1]">{response.reasoning}</span></div>
</div>
{/if}
@ -364,7 +369,7 @@
class="block text-6xl font-bold font-mono conf-number"
style="color: {confColor}; text-shadow: 0 0 24px {confColor}80;"
>
{conf}<span class="text-2xl align-top opacity-60">%</span>
<AnimatedNumber value={conf} /><span class="text-2xl align-top opacity-60">%</span>
</span>
</div>
<span
@ -418,7 +423,7 @@
<!-- Cognitive Pipeline visualization (how the engine got there) -->
<div class="space-y-3">
<h2 class="text-sm text-bright font-semibold flex items-center gap-2">
<span class="text-dream-glow"></span>
<span class="text-dream-glow"><Icon name="activation" size={15} /></span>
Cognitive Pipeline
</h2>
<div class="glass-panel rounded-2xl p-5">
@ -436,7 +441,7 @@
<div class="space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-sm text-bright font-semibold flex items-center gap-2">
<span class="text-synapse-glow"></span>
<span class="text-synapse-glow"><Icon name="memories" size={15} /></span>
Evidence
<span class="text-muted font-normal">({response.evidence.length})</span>
</h2>
@ -503,9 +508,9 @@
{#if response.contradictions.length > 0}
<div class="space-y-3">
<h2 class="text-sm font-semibold flex items-center gap-2" style="color: #fca5a5;">
<span></span>
<span><Icon name="contradictions" size={15} /></span>
Contradictions Detected
<span class="font-normal text-muted">({response.contradictions.length})</span>
<span class="font-normal text-muted">(<AnimatedNumber value={response.contradictions.length} />)</span>
</h2>
<div class="glass rounded-2xl p-4 space-y-3 !border-decay/30">
{#each response.contradictions as c, i}
@ -575,7 +580,7 @@
{#if response.related_insights.length > 0}
<div class="space-y-3">
<h2 class="text-sm text-bright font-semibold flex items-center gap-2">
<span class="text-dream-glow"></span>
<span class="text-dream-glow"><Icon name="sparkle" size={15} /></span>
Related Insights
</h2>
<div class="glass rounded-2xl p-4 space-y-2">
@ -592,8 +597,8 @@
<!-- Empty state -->
{#if !response && !loading && !error}
<div class="glass-subtle rounded-2xl p-12 text-center space-y-3">
<div class="text-5xl opacity-20"></div>
<div class="glass-subtle rounded-2xl p-12 text-center space-y-3 enter">
<div class="mx-auto w-fit text-dream-glow opacity-40 breathe"><Icon name="reasoning" size={44} strokeWidth={1.2} /></div>
<p class="text-sm text-dim">
Ask anything. Vestige will run the full reasoning pipeline and show you its work.
</p>

View file

@ -3,6 +3,12 @@
import { api } from '$stores/api';
import type { Memory } from '$types';
import FSRSCalendar from '$components/FSRSCalendar.svelte';
import PageHeader from '$components/PageHeader.svelte';
import Icon from '$components/Icon.svelte';
import Dropdown, { type DropdownOption } from '$components/Dropdown.svelte';
import AnimatedNumber from '$components/AnimatedNumber.svelte';
import { reveal } from '$lib/actions/reveal';
import { spotlight, magnetic } from '$lib/actions/interactions';
import {
classifyUrgency,
computeScheduleStats,
@ -80,36 +86,44 @@
}
}
// The filter buttons.
// The review windows.
const FILTERS: { key: WindowFilter; label: string }[] = [
{ key: 'today', label: 'Due today' },
{ key: 'week', label: 'This week' },
{ key: 'month', label: 'This month' },
{ key: 'all', label: 'All upcoming' }
];
// Live, badge-counted options for the window Dropdown. Each badge reflects
// the exact bucket size so the choice is CLEAR before the user even opens it.
let windowOptions = $derived<DropdownOption[]>([
{ value: 'today', label: 'Due today', icon: 'schedule', badge: stats.dueToday, color: '#fbbf24' },
{ value: 'week', label: 'This week', icon: 'schedule', badge: stats.dueThisWeek, color: '#818cf8' },
{ value: 'month', label: 'This month', icon: 'schedule', badge: stats.dueThisMonth, color: '#c084fc' },
{ value: 'all', label: 'All upcoming', icon: 'schedule', badge: scheduled.length, color: '#6ee7b7' }
]);
let activeFilterLabel = $derived(FILTERS.find((f) => f.key === windowFilter)?.label ?? '');
</script>
<div class="p-6 max-w-7xl mx-auto space-y-6">
<div class="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 class="text-xl text-bright font-semibold">Review Schedule</h1>
<p class="text-xs text-dim mt-1">FSRS-6 next-review dates across your memory corpus</p>
</div>
<div class="flex gap-1 p-1 glass-subtle rounded-xl">
{#each FILTERS as f}
<button
type="button"
onclick={() => (windowFilter = f.key)}
class="px-3 py-1.5 text-xs rounded-lg transition-all
{windowFilter === f.key
? 'bg-synapse/20 text-synapse-glow border border-synapse/30'
: 'text-dim hover:text-text hover:bg-white/[0.03] border border-transparent'}"
>
{f.label}
</button>
{/each}
</div>
</div>
<PageHeader
icon="schedule"
title="Review Schedule"
subtitle="FSRS-6 next-review dates across your memory corpus"
accent="warning"
>
<!-- Badge-counted window Dropdown — each option shows its exact bucket
size, so the choice is clear before the menu even opens. -->
<Dropdown
options={windowOptions}
bind:value={windowFilter}
label="Window"
icon="filter"
/>
</PageHeader>
{#if activeFilterLabel}<span class="sr-only">{activeFilterLabel}</span>{/if}
{#if !loading && !errored && truncated}
<div class="px-3 py-2 glass-subtle rounded-lg text-[11px] text-dim">
@ -121,16 +135,16 @@
{#if loading}
<div class="grid lg:grid-cols-[1fr_280px] gap-6">
<div class="space-y-3">
<div class="h-14 glass-subtle rounded-xl animate-pulse"></div>
<div class="h-14 glass-subtle rounded-xl shimmer"></div>
<div class="grid grid-cols-7 gap-2">
{#each Array(42) as _}
<div class="aspect-square glass-subtle rounded-lg animate-pulse"></div>
<div class="aspect-square glass-subtle rounded-lg shimmer"></div>
{/each}
</div>
</div>
<div class="space-y-3">
{#each Array(5) as _}
<div class="h-20 glass-subtle rounded-xl animate-pulse"></div>
<div class="h-20 glass-subtle rounded-xl shimmer"></div>
{/each}
</div>
</div>
@ -140,8 +154,8 @@
<p class="text-xs text-dim">Could not fetch memories from /api/memories.</p>
</div>
{:else if scheduled.length === 0}
<div class="p-10 glass rounded-xl text-center space-y-4">
<div class="text-4xl text-dream/40"></div>
<div class="p-10 glass rounded-xl text-center space-y-4 enter">
<div class="mx-auto w-fit text-dream/50 breathe"><Icon name="schedule" size={42} strokeWidth={1.2} /></div>
<p class="text-sm text-bright font-medium">FSRS review schedule not yet populated.</p>
<p class="text-xs text-dim max-w-md mx-auto">
None of your {memories.length} memor{memories.length === 1 ? 'y has' : 'ies have'} a

View file

@ -3,6 +3,9 @@
import { api } from '$stores/api';
import { websocket, isConnected, memoryCount, avgRetention } from '$stores/websocket';
import { fireDemoSequence } from '$stores/toast';
import PageHeader from '$lib/components/PageHeader.svelte';
import Icon from '$lib/components/Icon.svelte';
import { reveal } from '$lib/actions/reveal';
// v2.3 Birth Ritual demo — injects a synthetic MemoryCreated event so
// Graph3D spawns a birth orb without needing a real ingest. Node types
@ -80,43 +83,64 @@
}
</script>
<div class="p-6 max-w-4xl mx-auto space-y-8">
<div class="flex items-center justify-between">
<h1 class="text-xl text-bright font-semibold">Settings & System</h1>
<button onclick={loadAllData} class="text-xs text-dim hover:text-text transition">Refresh</button>
</div>
<div class="p-6 max-w-4xl mx-auto space-y-8 enter">
<PageHeader
icon="settings"
title="Settings & System"
subtitle="Tune the cognitive engine, watch the system breathe, and run the rituals that keep memory alive."
accent="synapse"
>
<span class="conn-pill" class:idle={!$isConnected}>
<span
class="conn-dot w-2 h-2 rounded-full"
class:ping-host={$isConnected}
class:breathe={!$isConnected}
style="color:{$isConnected ? 'var(--color-recall)' : 'var(--color-decay)'};background:{$isConnected ? 'var(--color-recall)' : 'var(--color-decay)'}"
></span>
<span>{$isConnected ? 'Connected' : 'Offline'}</span>
</span>
<button onclick={loadAllData} class="refresh-btn">
<Icon name="activation" size={13} />
<span>Refresh</span>
</button>
</PageHeader>
<!-- System Health Overview -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="p-4 glass rounded-xl text-center">
<div class="text-2xl text-bright font-bold">{$memoryCount}</div>
<div class="p-4 glass rounded-xl text-center lift" use:reveal={{ delay: 0 }}>
<div class="text-2xl text-bright font-bold tabular-nums">{$memoryCount}</div>
<div class="text-xs text-dim mt-1">Memories</div>
</div>
<div class="p-4 glass rounded-xl text-center">
<div class="text-2xl font-bold" style="color: {$avgRetention > 0.7 ? '#10b981' : $avgRetention > 0.4 ? '#f59e0b' : '#ef4444'}">{($avgRetention * 100).toFixed(1)}%</div>
<div class="p-4 glass rounded-xl text-center lift" use:reveal={{ delay: 60 }}>
<div class="text-2xl font-bold tabular-nums" style="color: {$avgRetention > 0.7 ? '#10b981' : $avgRetention > 0.4 ? '#f59e0b' : '#ef4444'}">{($avgRetention * 100).toFixed(1)}%</div>
<div class="text-xs text-dim mt-1">Avg Retention</div>
</div>
<div class="p-4 glass rounded-xl text-center">
<div class="p-4 glass rounded-xl text-center lift" use:reveal={{ delay: 120 }}>
<div class="text-2xl text-bright font-bold flex items-center justify-center gap-2">
<div class="w-2.5 h-2.5 rounded-full {$isConnected ? 'bg-recall animate-pulse-glow' : 'bg-decay'}"></div>
<span
class="w-2.5 h-2.5 rounded-full"
class:ping-host={$isConnected}
class:breathe={!$isConnected}
style="color:{$isConnected ? 'var(--color-recall)' : 'var(--color-decay)'};background:{$isConnected ? 'var(--color-recall)' : 'var(--color-decay)'}"
></span>
<span class="text-sm">{$isConnected ? 'Online' : 'Offline'}</span>
</div>
<div class="text-xs text-dim mt-1">WebSocket</div>
</div>
<div class="p-4 glass rounded-xl text-center">
<div class="p-4 glass rounded-xl text-center lift" use:reveal={{ delay: 180 }}>
<div class="text-2xl text-synapse-glow font-bold">v2.1</div>
<div class="text-xs text-dim mt-1">Vestige</div>
</div>
</div>
<!-- Cognitive Operations -->
<section class="space-y-4">
<section class="space-y-4" use:reveal={{ delay: 60 }}>
<h2 class="text-sm text-bright font-semibold flex items-center gap-2">
<span class="text-dream"></span> Cognitive Operations
<Icon name="dreams" size={16} class="text-dream" /> Cognitive Operations
</h2>
<!-- v2.2 Pulse — demo the InsightToast stream -->
<div class="p-4 glass rounded-xl space-y-3">
<div class="p-4 glass rounded-xl space-y-3 lift">
<div class="flex items-center justify-between">
<div>
<div class="text-sm text-text font-medium">Pulse Toast Preview</div>
@ -124,13 +148,13 @@
</div>
<button onclick={fireDemoSequence}
class="px-4 py-2 bg-synapse/20 border border-synapse/40 text-synapse-glow text-sm rounded-xl hover:bg-synapse/30 transition flex items-center gap-2">
<span></span> Preview Pulse
<Icon name="sparkle" size={14} /> Preview Pulse
</button>
</div>
</div>
<!-- v2.3 Terrarium — demo the Memory Birth Ritual on the Graph page -->
<div class="p-4 glass rounded-xl space-y-3">
<div class="p-4 glass rounded-xl space-y-3 lift">
<div class="flex items-center justify-between">
<div>
<div class="text-sm text-text font-medium">Birth Ritual Preview</div>
@ -138,13 +162,13 @@
</div>
<button onclick={fireBirthRitualDemo}
class="px-4 py-2 bg-dream/20 border border-dream/40 text-dream-glow text-sm rounded-xl hover:bg-dream/30 transition flex items-center gap-2">
<span></span> Trigger Birth
<Icon name="memories" size={14} /> Trigger Birth
</button>
</div>
</div>
<!-- Consolidation -->
<div class="p-4 glass rounded-xl space-y-3">
<div class="p-4 glass rounded-xl space-y-3 lift">
<div class="flex items-center justify-between">
<div>
<div class="text-sm text-text font-medium">FSRS-6 Consolidation</div>
@ -165,19 +189,19 @@
<div class="grid grid-cols-3 gap-3 text-center">
{#if consolidationResult.nodesProcessed !== undefined}
<div>
<div class="text-lg text-text font-semibold">{consolidationResult.nodesProcessed}</div>
<div class="text-lg text-text font-semibold tabular-nums">{consolidationResult.nodesProcessed}</div>
<div class="text-[10px] text-muted">Processed</div>
</div>
{/if}
{#if consolidationResult.decayApplied !== undefined}
<div>
<div class="text-lg text-decay font-semibold">{consolidationResult.decayApplied}</div>
<div class="text-lg text-decay font-semibold tabular-nums">{consolidationResult.decayApplied}</div>
<div class="text-[10px] text-muted">Decayed</div>
</div>
{/if}
{#if consolidationResult.embeddingsGenerated !== undefined}
<div>
<div class="text-lg text-synapse-glow font-semibold">{consolidationResult.embeddingsGenerated}</div>
<div class="text-lg text-synapse-glow font-semibold tabular-nums">{consolidationResult.embeddingsGenerated}</div>
<div class="text-[10px] text-muted">Embedded</div>
</div>
{/if}
@ -187,7 +211,7 @@
</div>
<!-- Dream -->
<div class="p-4 glass rounded-xl space-y-3">
<div class="p-4 glass rounded-xl space-y-3 lift">
<div class="flex items-center justify-between">
<div>
<div class="text-sm text-text font-medium">Memory Dream Cycle</div>
@ -227,9 +251,9 @@
<!-- Retention Distribution -->
{#if retentionDist}
<section class="space-y-4">
<section class="space-y-4" use:reveal={{ delay: 120 }}>
<h2 class="text-sm text-bright font-semibold flex items-center gap-2">
<span class="text-recall"></span> Retention Distribution
<Icon name="importance" size={16} class="text-recall" /> Retention Distribution
</h2>
<div class="p-4 glass rounded-xl">
{#if retentionDist.distribution && Array.isArray(retentionDist.distribution)}
@ -254,9 +278,9 @@
{/if}
<!-- Keyboard Shortcuts -->
<section class="space-y-4">
<section class="space-y-4" use:reveal={{ delay: 160 }}>
<h2 class="text-sm text-bright font-semibold flex items-center gap-2">
<span class="text-synapse"></span> Keyboard Shortcuts
<Icon name="command" size={16} class="text-synapse" /> Keyboard Shortcuts
</h2>
<div class="p-4 glass-subtle rounded-xl">
<div class="grid grid-cols-2 gap-2 text-xs">
@ -280,14 +304,14 @@
</section>
<!-- About -->
<section class="space-y-4">
<section class="space-y-4" use:reveal={{ delay: 200 }}>
<h2 class="text-sm text-bright font-semibold flex items-center gap-2">
<span class="text-memory"></span> About
<Icon name="logo" size={16} class="text-memory" /> About
</h2>
<div class="p-4 glass rounded-xl space-y-3">
<div class="p-4 glass rounded-xl space-y-3 lift">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-dream to-synapse flex items-center justify-center text-bright text-xl font-bold shadow-lg shadow-synapse/20">
V
<div class="logo-tile w-12 h-12 rounded-xl bg-gradient-to-br from-dream to-synapse flex items-center justify-center text-bright shadow-lg shadow-synapse/20">
<Icon name="logo" size={20} strokeWidth={1.8} />
</div>
<div>
<div class="text-sm text-bright font-semibold">Vestige v2.1 "Nuclear Dashboard"</div>
@ -308,3 +332,71 @@
</div>
</section>
</div>
<style>
/* ── Live connection pill in the header right-slot ──────────────────────── */
.conn-pill {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.7rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-recall, #34d399);
background: color-mix(in srgb, var(--color-recall, #34d399) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--color-recall, #34d399) 28%, transparent);
white-space: nowrap;
}
.conn-pill.idle {
color: var(--color-dim, #8b95a5);
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.08);
}
.conn-dot {
display: inline-block;
flex-shrink: 0;
}
/* ── Refresh button ─────────────────────────────────────────────────────── */
.refresh-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.65rem;
border-radius: 999px;
font-size: 0.75rem;
color: var(--color-muted, #6b7280);
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.06);
transition: color 0.2s ease, background 0.2s ease, border-color 0.2s ease;
}
.refresh-btn:hover {
color: var(--color-text, #e5e7eb);
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.12);
}
/* ── About logo tile — a soft synapse glow so the masthead reads alive ──── */
.logo-tile {
position: relative;
}
.logo-tile::after {
content: '';
position: absolute;
inset: -1px;
border-radius: inherit;
box-shadow: 0 0 18px -2px var(--color-synapse-glow, #818cf8);
opacity: 0.4;
pointer-events: none;
}
@media not (prefers-reduced-motion: reduce) {
.logo-tile::after {
animation: logo-glow 4s ease-in-out infinite;
}
@keyframes logo-glow {
0%, 100% { opacity: 0.25; }
50% { opacity: 0.55; }
}
}
</style>

View file

@ -2,6 +2,11 @@
import { onMount } from 'svelte';
import { api } from '$stores/api';
import type { SystemStats, HealthCheck, RetentionDistribution } from '$types';
import PageHeader from '$lib/components/PageHeader.svelte';
import AnimatedNumber from '$lib/components/AnimatedNumber.svelte';
import { reveal } from '$lib/actions/reveal';
import { spotlight } from '$lib/actions/interactions';
import { NODE_TYPE_COLORS } from '$types';
let stats: SystemStats | null = $state(null);
let health: HealthCheck | null = $state(null);
@ -37,45 +42,59 @@
</script>
<div class="p-6 max-w-5xl mx-auto space-y-6">
<h1 class="text-xl text-bright font-semibold">System Stats</h1>
<PageHeader
icon="stats"
title="System Stats"
subtitle="Live health and retention across your memory store"
accent="recall"
>
{#if health}
<div class="flex items-center gap-2 px-3 py-1.5 rounded-full glass-subtle text-xs" style="color: {statusColor(health.status)}">
<span class="ping-host w-2 h-2 rounded-full" style="color: {statusColor(health.status)}; background: {statusColor(health.status)}"></span>
<span class="font-semibold tracking-wide">{health.status.toUpperCase()}</span>
<span class="text-muted">v{health.version}</span>
</div>
{/if}
</PageHeader>
{#if loading}
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
{#each Array(8) as _}
<div class="h-24 glass-subtle rounded-xl animate-pulse"></div>
<div class="h-24 glass-subtle rounded-xl shimmer"></div>
{/each}
</div>
{:else if stats && health}
<!-- Status banner -->
<div class="flex items-center gap-3 p-4 glass rounded-xl" style="border-color: {statusColor(health.status)}30">
<div class="w-3 h-3 rounded-full animate-pulse-glow" style="background: {statusColor(health.status)}"></div>
<span class="text-sm font-medium" style="color: {statusColor(health.status)}">{health.status.toUpperCase()}</span>
<span class="text-xs text-dim">v{health.version}</span>
</div>
<!-- Key metrics -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="p-4 glass rounded-xl">
<div class="text-2xl text-bright font-bold">{stats.totalMemories}</div>
<div class="text-xs text-dim mt-1">Total Memories</div>
<div use:reveal={{ delay: 0 }} use:spotlight class="metric-card spotlight-surface lift p-4 glass rounded-xl">
<div class="relative z-[1] text-3xl text-bright font-bold">
<AnimatedNumber value={stats.totalMemories} />
</div>
<div class="relative z-[1] text-xs text-dim mt-1">Total Memories</div>
</div>
<div class="p-4 glass rounded-xl">
<div class="text-2xl font-bold" style="color: {stats.averageRetention > 0.7 ? '#10b981' : stats.averageRetention > 0.4 ? '#f59e0b' : '#ef4444'}">{(stats.averageRetention * 100).toFixed(1)}%</div>
<div class="text-xs text-dim mt-1">Avg Retention</div>
<div use:reveal={{ delay: 70 }} use:spotlight class="metric-card spotlight-surface lift p-4 glass rounded-xl">
<div class="relative z-[1] text-3xl font-bold" style="color: {stats.averageRetention > 0.7 ? 'var(--color-recall)' : stats.averageRetention > 0.4 ? 'var(--color-warning)' : 'var(--color-decay)'}">
<AnimatedNumber value={stats.averageRetention} scale={100} decimals={1} suffix="%" />
</div>
<div class="relative z-[1] text-xs text-dim mt-1">Avg Retention</div>
</div>
<div class="p-4 glass rounded-xl">
<div class="text-2xl text-bright font-bold">{stats.dueForReview}</div>
<div class="text-xs text-dim mt-1">Due for Review</div>
<div use:reveal={{ delay: 140 }} use:spotlight class="metric-card spotlight-surface lift p-4 glass rounded-xl">
<div class="relative z-[1] text-3xl text-bright font-bold">
<AnimatedNumber value={stats.dueForReview} />
</div>
<div class="relative z-[1] text-xs text-dim mt-1">Due for Review</div>
</div>
<div class="p-4 glass rounded-xl">
<div class="text-2xl text-bright font-bold">{stats.embeddingCoverage.toFixed(0)}%</div>
<div class="text-xs text-dim mt-1">Embedding Coverage</div>
<div use:reveal={{ delay: 210 }} use:spotlight class="metric-card spotlight-surface lift p-4 glass rounded-xl">
<div class="relative z-[1] text-3xl text-bright font-bold">
<AnimatedNumber value={stats.embeddingCoverage} decimals={0} suffix="%" />
</div>
<div class="relative z-[1] text-xs text-dim mt-1">Embedding Coverage</div>
</div>
</div>
<!-- Retention Distribution -->
{#if retention}
<div class="p-6 glass rounded-xl">
<div use:reveal class="p-6 glass rounded-xl">
<h2 class="text-sm text-bright font-semibold mb-4">Retention Distribution</h2>
<div class="flex items-end gap-1 h-40">
{#each retention.distribution as bucket, i}
@ -92,14 +111,14 @@
</div>
<!-- Type breakdown -->
<div class="p-6 glass-subtle rounded-xl">
<div use:reveal class="p-6 glass-subtle rounded-xl">
<h2 class="text-sm text-bright font-semibold mb-4">Memory Types</h2>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
{#each Object.entries(retention.byType) as [type, count]}
<div class="flex items-center gap-2 text-sm">
<div class="w-3 h-3 rounded-full" style="background: {({'fact':'#00A8FF','concept':'#9D00FF','event':'#FFB800','person':'#00FFD1','note':'#8B95A5','pattern':'#FF3CAC','decision':'#FF4757'})[type] || '#8B95A5'}"></div>
<span class="text-dim">{type}</span>
<span class="text-muted ml-auto">{count}</span>
<div class="flex items-center gap-2 text-sm rounded-lg px-2 py-1.5 hover:bg-white/[0.03] transition">
<div class="w-3 h-3 rounded-full" style="background: {NODE_TYPE_COLORS[type] || '#8B95A5'}; box-shadow: 0 0 8px {NODE_TYPE_COLORS[type] || '#8B95A5'}80"></div>
<span class="text-dim capitalize">{type}</span>
<span class="text-muted ml-auto tabular-nums"><AnimatedNumber value={count} /></span>
</div>
{/each}
</div>
@ -107,12 +126,18 @@
<!-- Endangered memories -->
{#if retention.endangered.length > 0}
<div class="p-6 glass rounded-xl !border-decay/20">
<h2 class="text-sm text-decay font-semibold mb-3">Endangered Memories ({retention.endangered.length})</h2>
<div class="space-y-2 max-h-48 overflow-y-auto">
<div use:reveal class="p-6 glass rounded-xl !border-decay/20">
<h2 class="text-sm text-decay font-semibold mb-3 flex items-center gap-2">
<span class="breathe inline-block w-2 h-2 rounded-full bg-decay text-decay"></span>
Endangered Memories ({retention.endangered.length})
</h2>
<div class="space-y-1 max-h-48 overflow-y-auto">
{#each retention.endangered.slice(0, 20) as m}
<div class="flex items-center gap-3 text-sm">
<span class="text-xs text-decay">{(m.retentionStrength * 100).toFixed(0)}%</span>
<div class="flex items-center gap-3 text-sm rounded-lg px-2 py-1 hover:bg-decay/[0.06] transition">
<span class="text-xs text-decay tabular-nums w-9">{(m.retentionStrength * 100).toFixed(0)}%</span>
<div class="w-16 h-1 rounded-full bg-deep overflow-hidden shrink-0">
<div class="h-full rounded-full bg-decay" style="width: {m.retentionStrength * 100}%"></div>
</div>
<span class="text-dim truncate">{m.content}</span>
</div>
{/each}
@ -122,11 +147,17 @@
{/if}
<!-- Actions -->
<div class="flex gap-3">
<div use:reveal class="flex gap-3">
<button onclick={runConsolidation}
class="px-4 py-2 bg-warning/20 border border-warning/40 text-warning text-sm rounded-xl hover:bg-warning/30 transition">
class="lift px-4 py-2 bg-warning/20 border border-warning/40 text-warning text-sm rounded-xl hover:bg-warning/30 transition">
Run Consolidation
</button>
</div>
{/if}
</div>
<style>
.metric-card {
cursor: default;
}
</style>

View file

@ -3,6 +3,11 @@
import { api } from '$stores/api';
import type { TimelineDay } from '$types';
import { NODE_TYPE_COLORS } from '$types';
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';
let timeline: TimelineDay[] = $state([]);
let loading = $state(true);
@ -22,29 +27,61 @@
loading = false;
}
}
// Day-range filter as a clear, labelled dropdown (was a bare native <select>).
const dayOptions: DropdownOption[] = [
{ value: '7', label: 'Last 7 days' },
{ value: '14', label: 'Last 14 days' },
{ value: '30', label: 'Last 30 days' },
{ value: '90', label: 'Last 90 days' },
{ value: '365', label: 'Last year' },
];
// `days` is a number; the Dropdown binds to a string. Keep a string mirror
// and reconcile through the change handler so reloads use the parsed value.
let daysChoice = $state('14');
function onDaysChange(v: string) {
days = parseInt(v, 10);
loadTimeline();
}
// Total memories across the loaded range — drives the live header count.
let totalMemories = $derived(timeline.reduce((sum, d) => sum + d.count, 0));
</script>
<div class="p-6 max-w-4xl mx-auto space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-xl text-bright font-semibold">Timeline</h1>
<select bind:value={days} onchange={loadTimeline}
class="px-3 py-2 bg-white/[0.03] border border-synapse/10 rounded-xl text-dim text-sm focus:outline-none backdrop-blur-sm">
<option value={7}>7 days</option>
<option value={14}>14 days</option>
<option value={30}>30 days</option>
<option value={90}>90 days</option>
</select>
</div>
<PageHeader
icon="timeline"
title="Timeline"
subtitle="Watch your memories accumulate, day by day"
accent="synapse"
>
<div class="flex items-center gap-4">
<span class="text-dim text-sm tabular-nums">
<AnimatedNumber value={totalMemories} /> memories
</span>
<Dropdown
options={dayOptions}
bind:value={daysChoice}
label="Range"
icon="schedule"
onChange={onDaysChange}
/>
</div>
</PageHeader>
{#if loading}
<div class="space-y-4">
{#each Array(7) as _}
<div class="h-16 glass-subtle rounded-xl animate-pulse"></div>
<div class="h-16 glass-subtle rounded-xl shimmer"></div>
{/each}
</div>
{:else if timeline.length === 0}
<div class="text-center py-20 text-dim">
<p>No memories in the selected time range.</p>
<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="timeline" size={48} strokeWidth={1.2} /></div>
<p class="text-dim text-sm max-w-sm">
No memories in this window yet — widen the range or come back once Vestige has
been remembering a while.
</p>
</div>
{:else}
<div class="relative">
@ -52,40 +89,43 @@
<div class="absolute left-6 top-0 bottom-0 w-px bg-synapse/15"></div>
<div class="space-y-4">
{#each timeline as day (day.date)}
<div class="relative pl-14">
{#each timeline as day, i (day.date)}
<div use:reveal={{ delay: Math.min(i * 35, 350), y: 12 }} class="relative pl-14">
<!-- Dot -->
<div class="absolute left-4 top-3 w-5 h-5 rounded-full border-2 border-synapse bg-void flex items-center justify-center">
<div class="w-2 h-2 rounded-full bg-synapse"></div>
<div class="w-2 h-2 rounded-full bg-synapse breathe"></div>
</div>
<button onclick={() => expandedDay = expandedDay === day.date ? null : day.date}
class="w-full text-left p-4 glass-subtle rounded-xl hover:bg-white/[0.03] transition-all">
class="lift w-full text-left p-4 glass-subtle rounded-xl hover:bg-white/[0.04] transition-all duration-200
{expandedDay === day.date ? '!border-synapse/40 glow-synapse' : ''}">
<div class="flex items-center justify-between">
<div>
<div class="flex items-baseline gap-2">
<span class="text-sm text-bright font-medium">{day.date}</span>
<span class="text-xs text-dim ml-2">{day.count} memories</span>
<span class="text-xs text-dim tabular-nums">
<AnimatedNumber value={day.count} /> memories
</span>
</div>
<!-- Dots for memory types -->
<div class="flex gap-1">
<div class="flex items-center gap-1">
{#each day.memories.slice(0, 10) as m}
<div class="w-2 h-2 rounded-full" style="background: {NODE_TYPE_COLORS[m.nodeType] || '#8B95A5'}; opacity: {0.3 + m.retentionStrength * 0.7}"></div>
<div class="w-2 h-2 rounded-full" style="background: {NODE_TYPE_COLORS[m.nodeType] || '#8B95A5'}; opacity: {0.3 + m.retentionStrength * 0.7}; box-shadow: 0 0 5px {NODE_TYPE_COLORS[m.nodeType] || '#8B95A5'}66"></div>
{/each}
{#if day.memories.length > 10}
<span class="text-xs text-muted">+{day.memories.length - 10}</span>
<span class="text-xs text-muted tabular-nums">+{day.memories.length - 10}</span>
{/if}
</div>
</div>
{#if expandedDay === day.date}
<div class="mt-3 pt-3 border-t border-synapse/10 space-y-2">
<div class="enter mt-3 pt-3 border-t border-synapse/10 space-y-2">
{#each day.memories as m}
<div class="flex items-start gap-2 text-sm">
<div class="w-2 h-2 mt-1.5 rounded-full flex-shrink-0" style="background: {NODE_TYPE_COLORS[m.nodeType] || '#8B95A5'}"></div>
<div class="flex-1 min-w-0">
<span class="text-dim line-clamp-1">{m.content}</span>
</div>
<span class="text-xs text-muted flex-shrink-0">{(m.retentionStrength * 100).toFixed(0)}%</span>
<span class="text-xs text-muted flex-shrink-0 tabular-nums">{(m.retentionStrength * 100).toFixed(0)}%</span>
</div>
{/each}
</div>

View file

@ -2,7 +2,7 @@
import '../app.css';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { goto, onNavigate } from '$app/navigation';
import { base } from '$app/paths';
import {
websocket,
@ -18,6 +18,7 @@
import AmbientAwarenessStrip from '$lib/components/AmbientAwarenessStrip.svelte';
import VerdictBar from '$lib/components/VerdictBar.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import Icon, { type IconName } from '$lib/components/Icon.svelte';
import { initTheme } from '$stores/theme';
let { children } = $props();
@ -80,23 +81,39 @@
};
});
const nav = [
{ href: '/graph', label: 'Graph', icon: '◎', shortcut: 'G' },
{ href: '/reasoning', label: 'Reasoning', icon: '✦', shortcut: 'R' },
{ href: '/memories', label: 'Memories', icon: '◈', shortcut: 'M' },
{ href: '/timeline', label: 'Timeline', icon: '◷', shortcut: 'T' },
{ href: '/feed', label: 'Feed', icon: '◉', shortcut: 'F' },
{ href: '/explore', label: 'Explore', icon: '◬', shortcut: 'E' },
{ href: '/activation', label: 'Activation', icon: '◈', shortcut: 'A' },
{ href: '/dreams', label: 'Dreams', icon: '✧', shortcut: 'D' },
{ href: '/schedule', label: 'Schedule', icon: '◷', shortcut: 'C' },
{ href: '/importance', label: 'Importance', icon: '◎', shortcut: 'P' },
{ href: '/duplicates', label: 'Duplicates', icon: '◉', shortcut: 'U' },
{ href: '/contradictions', label: 'Contradictions', icon: '⚠', shortcut: 'X' },
{ href: '/patterns', label: 'Patterns', icon: '▦', shortcut: 'N' },
{ href: '/intentions', label: 'Intentions', icon: '◇', shortcut: 'I' },
{ href: '/stats', label: 'Stats', icon: '◫', shortcut: 'S' },
{ href: '/settings', label: 'Settings', icon: '⚙', shortcut: ',' },
// Native View Transitions for client-side route navigation. Crossfades route
// changes when supported; respects prefers-reduced-motion. This replaces the
// old hand-rolled .animate-page-in keyframe on the route content wrapper.
onNavigate((navigation) => {
if (!document.startViewTransition || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
return new Promise((resolve) => {
document.startViewTransition(async () => {
resolve();
await navigation.complete;
});
});
});
// Each nav item carries a UNIQUE semantic icon (see Icon.svelte). The old
// set reused the same Unicode glyph across multiple items; every entry here
// now has a distinct silhouette that reads instantly.
const nav: { href: string; label: string; icon: IconName; shortcut: string }[] = [
{ href: '/graph', label: 'Graph', icon: 'graph', shortcut: 'G' },
{ href: '/reasoning', label: 'Reasoning', icon: 'reasoning', shortcut: 'R' },
{ href: '/memories', label: 'Memories', icon: 'memories', shortcut: 'M' },
{ href: '/timeline', label: 'Timeline', icon: 'timeline', shortcut: 'T' },
{ href: '/feed', label: 'Feed', icon: 'feed', shortcut: 'F' },
{ href: '/explore', label: 'Explore', icon: 'explore', shortcut: 'E' },
{ href: '/activation', label: 'Activation', icon: 'activation', shortcut: 'A' },
{ href: '/dreams', label: 'Dreams', icon: 'dreams', shortcut: 'D' },
{ href: '/schedule', label: 'Schedule', icon: 'schedule', shortcut: 'C' },
{ href: '/importance', label: 'Importance', icon: 'importance', shortcut: 'P' },
{ href: '/duplicates', label: 'Duplicates', icon: 'duplicates', shortcut: 'U' },
{ href: '/contradictions', label: 'Contradictions', icon: 'contradictions', shortcut: 'X' },
{ href: '/patterns', label: 'Patterns', icon: 'patterns', shortcut: 'N' },
{ href: '/intentions', label: 'Intentions', icon: 'intentions', shortcut: 'I' },
{ href: '/stats', label: 'Stats', icon: 'stats', shortcut: 'S' },
{ href: '/settings', label: 'Settings', icon: 'settings', shortcut: ',' },
];
// Mobile nav shows top 5 items
@ -135,11 +152,11 @@
<!-- Desktop Sidebar (hidden on mobile) -->
<nav class="hidden md:flex w-16 lg:w-56 flex-shrink-0 glass-sidebar flex-col">
<!-- Logo -->
<a href="{base}/graph" class="flex items-center gap-3 px-4 py-5 border-b border-synapse/10">
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-dream to-synapse flex items-center justify-center text-bright text-sm font-bold shadow-lg shadow-synapse/20">
V
<a href="{base}/graph" class="logo-link flex items-center gap-3 px-4 py-5 border-b border-synapse/10">
<div class="logo-mark w-8 h-8 rounded-lg bg-gradient-to-br from-dream to-synapse flex items-center justify-center text-bright shadow-lg shadow-synapse/20">
<Icon name="logo" size={18} strokeWidth={1.8} />
</div>
<span class="hidden lg:block text-sm font-semibold text-bright tracking-wide">VESTIGE</span>
<span class="hidden lg:block text-sm font-semibold text-bright tracking-[0.18em]">VESTIGE</span>
</a>
<!-- Nav items -->
@ -148,12 +165,14 @@
{@const active = isActive(item.href, $page.url.pathname)}
<a
href="{base}{item.href}"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 text-sm
class="nav-link group flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 text-sm
{active
? 'bg-synapse/15 text-synapse-glow border border-synapse/30 shadow-[0_0_12px_rgba(99,102,241,0.15)] nav-active-border'
: 'text-dim hover:text-text hover:bg-white/[0.03] border border-transparent'}"
>
<span class="text-base w-5 text-center">{item.icon}</span>
<span class="nav-icon w-5 flex justify-center transition-transform duration-200 group-hover:scale-110">
<Icon name={item.icon} size={18} />
</span>
<span class="hidden lg:block">{item.label}</span>
<span class="hidden lg:block ml-auto text-[10px] text-muted/50 font-mono">{item.shortcut}</span>
</a>
@ -166,8 +185,9 @@
onclick={() => { showCommandPalette = true; cmdQuery = ''; requestAnimationFrame(() => cmdInput?.focus()); }}
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-muted hover:text-dim hover:bg-white/[0.03] transition border border-subtle/15"
>
<span class="text-[10px] font-mono bg-white/[0.04] px-1.5 py-0.5 rounded">⌘K</span>
<Icon name="command" size={14} />
<span class="hidden lg:block">Command</span>
<span class="hidden lg:block ml-auto text-[10px] font-mono bg-white/[0.04] px-1.5 py-0.5 rounded">⌘K</span>
</button>
</div>
@ -201,7 +221,7 @@
<main class="flex-1 flex flex-col min-h-0 pb-16 md:pb-0">
<AmbientAwarenessStrip />
<VerdictBar />
<div class="animate-page-in flex-1 min-h-0 overflow-y-auto">
<div class="flex-1 min-h-0 overflow-y-auto">
{@render children()}
</div>
</main>
@ -216,7 +236,7 @@
class="flex flex-col items-center gap-0.5 px-3 py-2 rounded-lg transition-all min-w-[3.5rem]
{active ? 'text-synapse-glow' : 'text-muted'}"
>
<span class="text-lg">{item.icon}</span>
<Icon name={item.icon} size={20} />
<span class="text-[9px]">{item.label}</span>
</a>
{/each}
@ -246,7 +266,7 @@
>
<div class="w-full max-w-lg glass-panel rounded-xl shadow-2xl shadow-synapse/10 overflow-hidden">
<div class="flex items-center gap-3 px-4 py-3 border-b border-synapse/10">
<span class="text-synapse text-sm"></span>
<span class="text-synapse"><Icon name="search" size={16} /></span>
<input
bind:this={cmdInput}
bind:value={cmdQuery}
@ -267,7 +287,7 @@
onclick={() => cmdNavigate(item.href)}
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-dim hover:text-text hover:bg-white/[0.04] transition"
>
<span class="text-base w-5 text-center">{item.icon}</span>
<span class="w-5 flex justify-center"><Icon name={item.icon} size={17} /></span>
<span>{item.label}</span>
<span class="ml-auto text-[10px] text-muted/50 font-mono hidden md:block">{item.shortcut}</span>
</button>
@ -284,11 +304,25 @@
.safe-bottom {
padding-bottom: env(safe-area-inset-bottom, 0px);
}
@keyframes page-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
/* Logo breathes a faint synapse glow on hover — the mark feels live. */
.logo-mark {
transition:
transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
box-shadow 0.3s ease;
}
.animate-page-in {
animation: page-in 0.2s ease-out;
.logo-link:hover .logo-mark {
transform: rotate(-6deg) scale(1.08);
box-shadow:
0 0 0 1px rgba(129, 140, 248, 0.4),
0 0 22px rgba(99, 102, 241, 0.5);
}
/* The active nav item's icon picks up a soft drop-shadow glow so the
current location reads at a glance even in the collapsed (icon-only)
sidebar. */
.nav-link.text-synapse-glow .nav-icon :global(svg),
.nav-active-border .nav-icon :global(svg) {
filter: drop-shadow(0 0 6px rgba(129, 140, 248, 0.55));
}
</style>

172
docs/MEMORY_CINEMA.md Normal file
View file

@ -0,0 +1,172 @@
# Memory Cinema — Complete Feature Reference
Memory Cinema turns your real memory graph into a directed, narrated, infinitely-
diving cinematic experience rendered as a 150,000-particle WebGPU compute storm.
It is the dashboard's signature pillar.
The whole thing is **dynamically imported only on launch** — the heavy WebGPU/TSL
bundles never load for normal dashboard use. It boots a **separate WebGPU canvas**
so the underlying WebGL graph (every current user's experience) is never touched —
zero regression by construction.
## Architecture — the 8 modules
| Module | Role |
|---|---|
| `pathfinder.ts` | Plans the narrative path through the real graph (a story, not a BFS dump) |
| `topology.ts` | Extracts graph signals (betweenness, contradictions, surprise, decay) |
| `auteur.ts` | The director's brain — a typed cinematography grammar + shot-plan contract |
| `narrator.ts` | Captions — 3-tier narration (LLM / local / deterministic) |
| `director.ts` | The camera runtime — executes the shot plan frame-by-frame |
| `sandbox.ts` | The isolated WebGPU stage — renderer, camera, selective bloom |
| `storm.ts` | The 150k-particle GPU compute storm — physics, geometry, color, immersion |
| `components/MemoryCinema.svelte` | The orchestration overlay — input, dream mode, UI |
## Tier 1 — the narrative path (`pathfinder.ts`)
The bulletproof core that always runs, using only the nodes + edges the backend
returns. It plans a story: start at the origin (focused memory) → visit its
strongest-weighted connections → detour to a contradiction edge if one exists
(tension = interesting) → end on a recently-created node ("where the mind is now").
Each stop is a beat with a `kind` (origin/connection/contradiction/recent/bridge/
surprise). Falls back to weighted BFS. No LLM, no WebGPU, no network — if
everything else fails this alone produces a coherent, watchable flythrough.
## Tier 2 — graph signal extraction (`topology.ts`)
Pure statistics over the real `/api/graph` data, computed once per launch:
- **Brandes betweenness centrality** — the most load-bearing memory (graph keystone)
- **Connected-component clustering**
- **Contradiction detection** + **merge/supersede detection**
- **Surprise score** (shared neighbors yet low edge weight = non-obvious link)
- **Recency rank**, **FSRS retention**, **suppression pressure**
## The Auteur — the director's brain (`auteur.ts`)
A real cinematography grammar applied to your memories. The director (LLM Tier-1 or
a deterministic rule table Tier-2) produces a `DirectorPlan`: typed `Shot`s, each
grounded in a real node and justified by a real metric.
- **Moves:** push_in, pull_back, orbit, crane, whip_pan, rack_focus, hold
- **Angles:** low = look up (power), high = look down (decay), eye
- **Cuts:** fly, hard_cut, match_cut
- **Per shot:** dutch roll, standoff, flight/dwell seconds, spring half-life,
intensity, tension (01)
- **Three acts** (I/II/III), an **emotional arc** (man_in_hole, rags_to_riches,
icarus, cinderella, oedipus, flat), a **caption tone**, a **score cue**
- A **required `why`** per shot citing the real graph metric
- **Carry-forward semantics:** sparse/half-hallucinated plans always resolve to a
coherent film.
## Narration (`narrator.ts`)
- **Tier 1:** backend LLM (`/api/narrative`) or opt-in on-device model (lazy-loaded
only when "Local AI" enabled; never downloads weights unprompted).
- **Tier 2:** deterministic structured captions from real data — instant, no network.
- **Tier 3:** can't fail — falls back to Tier 2.
- Optional voice via `speechSynthesis`; typewriter caption stream (instant under
reduced-motion).
## The 150k-particle storm (`storm.ts`)
150,000 particles whose physics run entirely on the GPU via Three Shading Language
compute nodes. One particle pool, one compute kernel, ~32 uniforms.
### The 7-beat world journey
Each beat is a unique world — particles aren't swapped, only the forces (uWorld
selects; uBlend crossfades over ~1s):
0 nebula mist (curl-noise flow) · 1 orbital anchor (cross-product spin) · 2 strange
attractor (Thomas) · 3 detonation void · 4 crystal lattice (voxel snap) · 5 fluid
galaxy (curl + swirl) · 6 phyllotaxis bloom (Vogel sunflower).
### The impossible-geometry form pack (dream worlds 711)
Forms nobody renders as living particles, mapped over a (u,v) manifold grid (387²)
so they read as a sculpted skin, not spaghetti:
7 supershape (3D superformula) · 8 CalabiYau (6D string-theory manifold, 4D→3D
projection; α rotates through the 4th dimension) · 9 Boy's surface (non-orientable
minimal immersion) · 10 Aizawa attractor shell · 11 gyroid↔Schwarz-D (triply-
periodic minimal surface morph). Inline complex-math + hyperbolics (sinh/cosh
expanded via exp; absent in three@0.172).
### Color
- Full-spectrum iridescent palette (per-particle phase + radial shells + spatial
bands + time + global drift).
- Per-world cosine (IQ) palettes — each world a distinct identity.
- **The Color Blast:** a long-lived uBlast envelope (~2.8s, decoupled from the fast
physics burst so color outlives the shockwave) drives an outward spectral-
dispersion wave (uBlastTime; prism order) over a blackbody ember core. Spectrum
dominates (reads rainbow, not white); capped at 0.6 mix.
- **Jarring inner/outer clash:** the nested figure and shell use opposing color
universes (ice↔fire, acid↔blood, gold↔violet, mint↔crimson, electric↔gold);
uClash cycles the pair every beat.
### 3D-within-3D nesting
~34% of particles form a second, smaller, counter-rotating figure (a different
world, ~52% scale) inside the outer shell — a figure within a figure.
### Anti-white-out / "solid" systems
- Rim glow (Fresnel): bright edges, dim readable center.
- Emissive routing: the rainbow goes to BOTH colorNode and emissiveNode (the
selective bloom reads emissive — the original white-out was an unset emissive).
- Hollow-shell spawn (the old tiny dense ball flashed white on frame 0).
- Act/beat-aware brightness (uActDim): beats 0/1 fade in soft, Acts II/III blaze.
### The immersion stack
- **Infinite Droste zoom** (uZoomPeriod/uLambda/uZoomOn): the cloud dives inward
forever. Two layers ride offset phases of fract(uTime/T); the outer grows
pow(λ, phase) then snaps back invisibly (λ=1.923=1/0.52 makes inner→outer exact);
the inner promotes by λ to become the next shell; a fresh inner spawns inside. A
sin(phase·π) seam cross-fade in rimFactor makes each layer transparent at its
snap → zero pop, seamless loop. Particle-space, not a camera dolly.
- **Velocity-stretch flythrough** (uCamVelView/uStreak/uMaxStretch): sprites stretch
along screen-space camera velocity into warp streaks; the camera clamp floor
relaxes (30→6) so it plunges inside the shell.
- **DOF + volumetric fog** (uFocus/uFocusRange/uDofDim/uFogDensity): off-focus
particles dim (bokeh under bloom) + exp-falloff fog tints the far field → 3D
atmospheric depth; rack-focus tracks the dive. All folded into a single depthFade
Fn (three@0.172 constraint: positionView may be read from only ONE Fn per
material output or the node-builder stack-overflows).
- **Interactive parallax** (overlay): pointer orbits, scroll/pinch zooms, damped;
composed onto the director's base pose; idle eases back to 0; the sandbox re-clamp
means framing can't break.
### Physics safety
Hard velocity clamp, strong damping, boundary snap, dt clamp, serialized compute
dispatches.
## Endless dream mode
When the 7-beat tour ends, instead of freezing it drops into an infinite generative
loop (`dreamBeat()` every ~5.5s): a fresh random procedural figure (worlds 711, new
uMorphSeed → never the same twice), uChaos ramps up so each is wilder than the last,
a random clash pair, full color blast, infinite zoom + flythrough on. Overlay shows
"∞ Dreaming." Never idle.
## The stage (`sandbox.ts`)
- Separate WebGPU renderer + scene + PerspectiveCamera (clamped to a safe distance
band, always lookAt(origin) → the storm can never leave frame).
- Selective MRT bloom — blooms only the emissive channel against a clean void; falls
back to a plain pass if MRT is unavailable on a driver.
- Per-frame camera-velocity tracking (one Vector3, zero compute) feeding the streak.
- Pass-throughs: setZoom, setStreak, setFlythrough, setCameraVel, setContainRadius.
## Overlay & UX (`MemoryCinema.svelte`)
- Fullscreen launch, staged status (idle → planning → playing → done).
- Pre-roll "Director's Plan" card (logline + arc).
- Live captions, beat chips, director's-note ("why," citing a real metric), act
indicator, tension sparkline, progress bar.
- WebGPU / Auteur / Live-captions badges; Voice + Local AI toggles.
- `H` hides all chrome for clean demo capture (faint restore hint; `body.cinema-open`
hides the graph page's stats pill; overlay z-index 200).
- Replay on completion.
- Graceful degradation: if WebGPU is unavailable or render fails 3× consecutively,
drops to camera-only (captions still play) — never stalls.
- Reduced-motion fully honored: no parallax listeners, zoom/flythrough/streak gated
off, jump-cuts instead of flights, instant captions.
## Protection contract
On the shared graph page, `<MemoryCinema>` is wrapped in a clearly-commented
`PROTECTED MEMORY CINEMA — DO NOT MODIFY` boundary so the graph-control overhaul and
the cinema engine stay visibly distinct.