feat(dashboard): alive overhaul — unique icons, dropdowns, motion on every page

Make the dashboard feel alive every second, with clear controls, for the
July 14 HN relaunch. Memory Cinema is left fully untouched (zero changes to
MemoryCinema.svelte / graph/cinema/*; its tests still pass).

Foundation (lifts every page):
- Icon.svelte: inline-SVG icon system, zero runtime dep. A UNIQUE semantic
  silhouette per nav item — kills the old duplicated Unicode glyphs (◎◈◉◷
  were each reused across multiple items). Wired into sidebar, mobile nav,
  command palette, logo.
- Dropdown.svelte: accessible, keyboard-nav, type-ahead, animated select
  replacement with color dots / badges. Replaces dead native <select>s.
- AnimatedNumber.svelte: rAF count-up/tween, reduced-motion safe.
- PageHeader.svelte: shared masthead (drawn route icon + aurora title).
- actions/reveal.ts + actions/interactions.ts: scroll-reveal, magnetic,
  tilt(+glare), spotlight — all no-op under reduced-motion.
- app.css "alive layer": @property animatable gradients, conic live-border,
  breathe/ping, shimmer skeletons, @starting-style entry, aurora text, lift.

Per-page: every route (graph non-cinema controls, reasoning, memories,
timeline, feed, explore, activation, dreams, schedule, importance,
duplicates, contradictions, patterns, intentions, stats, settings) now uses
PageHeader, real Icons, count-ups, staggered reveals, shimmer loaders,
spotlight cards, and warm empty states. Native selects and button-row
filters became clear Dropdowns where it improves clarity.

Gates: svelte-check 0 errors/0 warnings, 937/937 tests pass, build green,
verified live in the browser preview.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-06-22 02:58:26 -05:00
parent bc81da46eb
commit 1fbbecb0b3
24 changed files with 2360 additions and 585 deletions

View file

@ -301,3 +301,274 @@ body {
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

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

@ -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,7 +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';
@ -21,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.
@ -204,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.
@ -235,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>
@ -321,14 +341,14 @@ disown</code>
</button>
</div>
<!-- Node count -->
<select bind:value={maxNodes} onchange={() => loadGraph()}
class="shrink-0 min-h-10 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. -->
@ -355,14 +375,19 @@ disown</code>
<button
onclick={triggerDream}
disabled={isDreaming}
class="shrink-0 min-h-10 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 — AI-narrated flythrough of the real graph -->
<!-- ═══════════════════════════════════════════════════════════
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}
@ -370,11 +395,15 @@ disown</code>
centerId={graphData?.center_id ?? ''}
/>
{/if}
<!-- ═══════════ END PROTECTED MEMORY CINEMA ═══════════ -->
<!-- Reload -->
<button onclick={() => loadGraph()}
class="shrink-0 min-h-10 min-w-10 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>
@ -501,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

@ -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();
@ -93,23 +94,26 @@
});
});
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: ',' },
// 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
@ -148,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 -->
@ -161,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>
@ -179,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>
@ -229,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}
@ -259,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}
@ -280,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>
@ -297,4 +304,25 @@
.safe-bottom {
padding-bottom: env(safe-area-inset-bottom, 0px);
}
/* 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;
}
.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>