mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-24 23:41:01 +02:00
merge: dashboard cinema foundation (lane 1/5)
This commit is contained in:
commit
f80c51ee59
38 changed files with 6160 additions and 604 deletions
27
CLAUDE.md
27
CLAUDE.md
|
|
@ -1,3 +1,30 @@
|
|||
# 🚀 MANDATE #0 — MAXIMUM AMBITION. NO HEDGING. (read this first, every time)
|
||||
|
||||
This is the standing default for ALL work on Vestige, before anything else below.
|
||||
|
||||
- **Assume maximum ambition.** Build the most exhaustive, jaw-dropping, correct
|
||||
version — not the safe "good enough" one. The ceiling is "nobody else has
|
||||
this," not "this works." Default to the winning move, not the cautious one.
|
||||
- **Scour before you settle.** When unsure or stuck, research the real frontier
|
||||
(web, source, the actual running app) before acting. Never accept the first
|
||||
adequate answer when a deeper one exists. Verify, don't assume.
|
||||
- **No hedging.** Lead with the best path and a clear recommendation. Forbidden:
|
||||
"this is probably too risky," "temper expectations," "good enough," "maybe try
|
||||
the easier one." Risks get their own honest section — never used to shrink the
|
||||
target.
|
||||
- **Show proof.** Verify changes in the real running app and share the evidence
|
||||
(screenshots, test output, gate results) — don't claim done without it.
|
||||
- **Protect what's flawless, detonate what isn't.** Treat finished, loved work as
|
||||
load-bearing (don't break it); push everything else past where any other dev
|
||||
would stop.
|
||||
|
||||
Origin: Sam, Jun 22 2026 — the overnight session that turned the dashboard +
|
||||
Memory Cinema from "alive" into a category-of-one particle journey. The depth
|
||||
only happened because the bar was set to maximum. Make that the default, not the
|
||||
exception.
|
||||
|
||||
---
|
||||
|
||||
# Vestige Agent Guidance
|
||||
|
||||
This file is intentionally safe for the public repository. It gives coding
|
||||
|
|
|
|||
|
|
@ -43,6 +43,43 @@ html {
|
|||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
OKLCH / DISPLAY-P3 ACCENT PALETTE (PROGRESSIVE ENHANCEMENT)
|
||||
═══════════════════════════════════════════
|
||||
The @theme block above keeps the original sRGB hex values, which
|
||||
Tailwind reads at build time and which serve as the fallback for
|
||||
sRGB monitors and browsers without OKLCH support.
|
||||
|
||||
When the browser understands oklch(), we redefine the SAME vivid
|
||||
accents + node-type colors using their OKLCH equivalents. These are
|
||||
faithful conversions of the hex values (same hue/chroma identity);
|
||||
on a wide-gamut display-p3 monitor they render more saturated while
|
||||
reading as the same color. The void/abyss/surface neutrals are left
|
||||
untouched — only the vivid accents benefit from the wider gamut. */
|
||||
@supports (color: oklch(0 0 0)) {
|
||||
:root {
|
||||
/* Accent colors */
|
||||
--color-synapse: oklch(0.585 0.222 277);
|
||||
--color-synapse-glow: oklch(0.685 0.169 277);
|
||||
--color-dream: oklch(0.627 0.265 304);
|
||||
--color-dream-glow: oklch(0.714 0.203 305);
|
||||
--color-memory: oklch(0.623 0.214 259);
|
||||
--color-recall: oklch(0.696 0.17 162);
|
||||
--color-decay: oklch(0.637 0.237 25);
|
||||
--color-warning: oklch(0.769 0.188 70);
|
||||
|
||||
/* Node type colors */
|
||||
--color-node-fact: oklch(0.623 0.214 259);
|
||||
--color-node-concept: oklch(0.606 0.25 292);
|
||||
--color-node-event: oklch(0.769 0.188 70);
|
||||
--color-node-person: oklch(0.696 0.17 162);
|
||||
--color-node-place: oklch(0.715 0.143 215);
|
||||
--color-node-note: oklch(0.551 0.027 264);
|
||||
--color-node-pattern: oklch(0.656 0.241 354);
|
||||
--color-node-decision: oklch(0.637 0.237 25);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
|
|
@ -234,3 +271,304 @@ body {
|
|||
.retention-low { color: var(--color-warning); }
|
||||
.retention-good { color: var(--color-recall); }
|
||||
.retention-strong { color: var(--color-synapse); }
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
VIEW TRANSITIONS (CROSSFADE)
|
||||
═══════════════════════════════════════════
|
||||
Native View Transitions API crossfade between routes. Pairs with the
|
||||
onNavigate hook in +layout.svelte that calls document.startViewTransition.
|
||||
Reduced-motion users get an instant cut (the @media guard disables the
|
||||
animation entirely, so the default browser cross-fade does not run). */
|
||||
@media not (prefers-reduced-motion: reduce) {
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation-duration: 180ms;
|
||||
animation-timing-function: ease;
|
||||
}
|
||||
::view-transition-old(root) {
|
||||
animation-name: vt-fade-out;
|
||||
}
|
||||
::view-transition-new(root) {
|
||||
animation-name: vt-fade-in;
|
||||
}
|
||||
|
||||
@keyframes vt-fade-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
@keyframes vt-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
THE "ALIVE" LAYER (bleeding-edge, progressively enhanced)
|
||||
───────────────────────────────────────────────────────────────────────
|
||||
Shared primitives that make every page breathe, respond, and feel seen.
|
||||
Everything here degrades gracefully and is disabled under reduced-motion.
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Registered animatable custom props (@property) ──────────────────────
|
||||
Registering a property as <angle>/<percentage> lets the browser TWEEN it,
|
||||
which plain CSS vars can't do. Powers the rotating conic borders below. */
|
||||
@property --angle {
|
||||
syntax: '<angle>';
|
||||
initial-value: 0deg;
|
||||
inherits: false;
|
||||
}
|
||||
@property --shine {
|
||||
syntax: '<percentage>';
|
||||
initial-value: 0%;
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
/* ── Scroll-reveal (paired with use:reveal) ─────────────────────────────
|
||||
Elements start translated + transparent; .reveal-in lands them. The
|
||||
action toggles the class via IntersectionObserver, with per-item --reveal-delay
|
||||
for staggered list entrances. */
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(var(--reveal-y, 16px));
|
||||
transition:
|
||||
opacity 0.55s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
transform 0.55s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
transition-delay: var(--reveal-delay, 0ms);
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
.reveal-in {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── @starting-style page/section entry ─────────────────────────────────
|
||||
Native entry animation with zero JS: the element animates FROM the
|
||||
starting-style the first time it's rendered. Used on route content. */
|
||||
@media not (prefers-reduced-motion: reduce) {
|
||||
.enter {
|
||||
transition:
|
||||
opacity 0.4s ease,
|
||||
transform 0.4s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
@starting-style {
|
||||
.enter {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Conic-gradient animated border (premium "live" frame) ──────────────
|
||||
A slowly rotating iridescent ring around a panel. The @property --angle
|
||||
makes the conic gradient's start angle tweenable. Use class .live-border. */
|
||||
.live-border {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
.live-border::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
background: conic-gradient(
|
||||
from var(--angle),
|
||||
transparent 0%,
|
||||
var(--color-synapse) 18%,
|
||||
var(--color-dream) 33%,
|
||||
transparent 50%,
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
z-index: -1;
|
||||
}
|
||||
@media not (prefers-reduced-motion: reduce) {
|
||||
.live-border::before {
|
||||
animation: border-rotate 6s linear infinite;
|
||||
}
|
||||
@keyframes border-rotate {
|
||||
to {
|
||||
--angle: 360deg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Cursor spotlight surface (paired with use:spotlight) ────────────────
|
||||
A soft radial glow follows the pointer across a panel. --spot-x/y/o are
|
||||
set by the action; default opacity 0 so it's invisible until hovered. */
|
||||
.spotlight-surface {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.spotlight-surface::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
340px circle at var(--spot-x, 50%) var(--spot-y, 50%),
|
||||
rgba(129, 140, 248, 0.12),
|
||||
transparent 60%
|
||||
);
|
||||
opacity: var(--spot-o, 0);
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* ── Tilt glare (paired with use:tilt glare) ────────────────────────────── */
|
||||
.tilt-glare {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tilt-glare::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
circle at var(--glare-x, 50%) var(--glare-y, 50%),
|
||||
rgba(255, 255, 255, 0.14),
|
||||
transparent 45%
|
||||
);
|
||||
opacity: var(--glare-o, 0);
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
/* ── Breathing live indicator (calmer than a hard blink) ────────────────
|
||||
A dot that gently inhales/exhales scale + glow — reads as "alive,
|
||||
listening" rather than "error blinking". */
|
||||
.breathe {
|
||||
animation: breathe 3.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes breathe {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
filter: drop-shadow(0 0 2px currentColor);
|
||||
opacity: 0.85;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.18);
|
||||
filter: drop-shadow(0 0 7px currentColor);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.breathe { animation: none; }
|
||||
}
|
||||
|
||||
/* A live "radar ping" ring that expands and fades — wrap a dot in .ping-host. */
|
||||
.ping-host {
|
||||
position: relative;
|
||||
}
|
||||
.ping-host::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.6;
|
||||
z-index: -1;
|
||||
}
|
||||
@media not (prefers-reduced-motion: reduce) {
|
||||
.ping-host::before {
|
||||
animation: ping 2.4s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||
}
|
||||
@keyframes ping {
|
||||
0% { transform: scale(1); opacity: 0.5; }
|
||||
80%, 100% { transform: scale(2.6); opacity: 0; }
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Shimmer skeleton (loading that feels intentional, not frozen) ───────
|
||||
A diagonal light sweep over skeleton blocks. Use .shimmer on the
|
||||
placeholder; it replaces a plain pulse with a directional sheen. */
|
||||
.shimmer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.shimmer::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: translateX(-100%);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(129, 140, 248, 0.12),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
@media not (prefers-reduced-motion: reduce) {
|
||||
.shimmer::after {
|
||||
animation: shimmer 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Gradient text that slowly drifts (alive headings) ──────────────────── */
|
||||
.text-aurora {
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
var(--color-synapse-glow),
|
||||
var(--color-dream-glow),
|
||||
var(--color-recall),
|
||||
var(--color-synapse-glow)
|
||||
);
|
||||
background-size: 250% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
@media not (prefers-reduced-motion: reduce) {
|
||||
.text-aurora {
|
||||
animation: aurora-drift 8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes aurora-drift {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Hover lift utility — cards rise + glow when pointed at ───────────────*/
|
||||
.lift {
|
||||
transition:
|
||||
transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
box-shadow 0.28s ease,
|
||||
border-color 0.28s ease;
|
||||
}
|
||||
.lift:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(99, 102, 241, 0.18);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lift:hover { transform: none; }
|
||||
}
|
||||
|
||||
/* ── Tabular numerals helper (count-ups don't jitter) ──────────────────── */
|
||||
.tabular-nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
|
||||
/* ── Focus ring consistency (accessible + on-brand) ─────────────────────── */
|
||||
:where(button, a, input, select, [role='button'], [tabindex]):focus-visible {
|
||||
outline: 2px solid var(--color-synapse-glow);
|
||||
outline-offset: 2px;
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
|
|
|
|||
136
apps/dashboard/src/lib/actions/interactions.ts
Normal file
136
apps/dashboard/src/lib/actions/interactions.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
68
apps/dashboard/src/lib/actions/reveal.ts
Normal file
68
apps/dashboard/src/lib/actions/reveal.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
91
apps/dashboard/src/lib/components/AnimatedNumber.svelte
Normal file
91
apps/dashboard/src/lib/components/AnimatedNumber.svelte
Normal 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>
|
||||
338
apps/dashboard/src/lib/components/Dropdown.svelte
Normal file
338
apps/dashboard/src/lib/components/Dropdown.svelte
Normal 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>
|
||||
|
|
@ -50,6 +50,20 @@
|
|||
let ctx: SceneContext;
|
||||
let animationId: number;
|
||||
|
||||
// Accessibility: honour the OS "reduce motion" setting. The dominant
|
||||
// continuous motion in the graph is the camera auto-rotate; disabling it
|
||||
// removes the vestibular-trigger while keeping the graph fully usable
|
||||
// (manual orbit, hover, selection, live events all still work). Tracked
|
||||
// reactively so a mid-session OS toggle is respected.
|
||||
let prefersReducedMotion =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
let reducedMotionMq: MediaQueryList | null = null;
|
||||
function onReducedMotionChange(e: MediaQueryListEvent) {
|
||||
prefersReducedMotion = e.matches;
|
||||
if (ctx?.controls) ctx.controls.autoRotate = !prefersReducedMotion;
|
||||
}
|
||||
|
||||
// Modules
|
||||
let nodeManager: NodeManager;
|
||||
let edgeManager: EdgeManager;
|
||||
|
|
@ -74,6 +88,15 @@
|
|||
onMount(() => {
|
||||
ctx = createScene(container);
|
||||
|
||||
// Respect reduced-motion: the scene defaults to auto-rotate on; turn it
|
||||
// off up front for users who asked for less motion, and listen for live
|
||||
// OS-setting changes.
|
||||
if (prefersReducedMotion) ctx.controls.autoRotate = false;
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
reducedMotionMq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
reducedMotionMq.addEventListener?.('change', onReducedMotionChange);
|
||||
}
|
||||
|
||||
// Nebula background
|
||||
const nebula = createNebulaBackground(ctx.scene);
|
||||
nebulaMaterial = nebula.material;
|
||||
|
|
@ -113,6 +136,7 @@
|
|||
onDestroy(() => {
|
||||
cancelAnimationFrame(animationId);
|
||||
window.removeEventListener('resize', onResize);
|
||||
reducedMotionMq?.removeEventListener?.('change', onReducedMotionChange);
|
||||
container?.removeEventListener('pointermove', onPointerMove);
|
||||
container?.removeEventListener('click', onClick);
|
||||
effects?.dispose();
|
||||
|
|
|
|||
143
apps/dashboard/src/lib/components/Icon.svelte
Normal file
143
apps/dashboard/src/lib/components/Icon.svelte
Normal 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>
|
||||
831
apps/dashboard/src/lib/components/MemoryCinema.svelte
Normal file
831
apps/dashboard/src/lib/components/MemoryCinema.svelte
Normal file
|
|
@ -0,0 +1,831 @@
|
|||
<script lang="ts">
|
||||
// Memory Cinema — the orchestration layer (Phase 4).
|
||||
//
|
||||
// Ties the three tiers together into one fullscreen experience:
|
||||
// Tier 3 (always): BFS pathfinder + camera director drive the flythrough.
|
||||
// Tier 2 (default): local structured captions from real node/edge data.
|
||||
// Tier 1 (opt-in / when available): richer narration from a backend LLM
|
||||
// endpoint, or the opt-in on-device model (lazy-loaded only on click).
|
||||
// WebGPU storm renders if supported; otherwise camera + captions still play.
|
||||
//
|
||||
// Launches in an isolated overlay with its own canvas — the WebGL graph
|
||||
// underneath is never touched.
|
||||
|
||||
import { onDestroy } from 'svelte';
|
||||
import * as THREE from 'three';
|
||||
import type { GraphNode, GraphEdge } from '$types';
|
||||
import { planCinemaPath, type CinemaPath, type CinemaBeat } from '$lib/graph/cinema/pathfinder';
|
||||
import { CinemaDirector } from '$lib/graph/cinema/director';
|
||||
import {
|
||||
resolveNarration,
|
||||
localCaptions,
|
||||
type CinemaNarration,
|
||||
type BeatNarration,
|
||||
} from '$lib/graph/cinema/narrator';
|
||||
import { computeSignals } from '$lib/graph/cinema/topology';
|
||||
import {
|
||||
planShotsDeterministic,
|
||||
resolveShots,
|
||||
type DirectorPlan,
|
||||
type ResolvedShot,
|
||||
type StormMode,
|
||||
} from '$lib/graph/cinema/auteur';
|
||||
import type { SemanticRole } from '$lib/graph/cinema/storm';
|
||||
import type { CinemaSandbox } from '$lib/graph/cinema/sandbox';
|
||||
|
||||
interface Props {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
centerId: string;
|
||||
/** Optional Tier-1 backend narration fetcher (passed when backend supports it). */
|
||||
fetchBackendNarration?: () => Promise<BeatNarration[] | null>;
|
||||
}
|
||||
let { nodes, edges, centerId, fetchBackendNarration }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let stage = $state<'idle' | 'planning' | 'playing' | 'done'>('idle');
|
||||
let caption = $state('');
|
||||
let chip = $state('');
|
||||
let progress = $state(0);
|
||||
let beatIndex = $state(0);
|
||||
let totalBeats = $state(0);
|
||||
let narrationSource = $state<CinemaNarration['source'] | null>(null);
|
||||
let webgpuActive = $state(false);
|
||||
let voiceOn = $state(false);
|
||||
let localAiOn = $state(false);
|
||||
// Hide all UI chrome (top bar + captions) for a clean demo capture. Toggle
|
||||
// with H. Default on; flip to off to record the pure storm with nothing
|
||||
// blocking the visuals.
|
||||
let cinemaChrome = $state(true);
|
||||
let statusLine = $state('');
|
||||
// Auteur (director) state surfaced in the overlay.
|
||||
let directorNote = $state(''); // the current shot's "why" (cites a real metric)
|
||||
let act = $state<'I' | 'II' | 'III'>('I');
|
||||
let tension = $state(0); // 0..1 for the tension sparkline
|
||||
let logline = $state('');
|
||||
let plan = $state<DirectorPlan | null>(null);
|
||||
|
||||
let canvasHost = $state<HTMLDivElement | undefined>(undefined);
|
||||
let sandbox: CinemaSandbox | null = null;
|
||||
let director: CinemaDirector | null = null;
|
||||
let path: CinemaPath | null = null;
|
||||
let narration: CinemaNarration | null = null;
|
||||
let rafId = 0;
|
||||
let lastFrame = 0;
|
||||
let typeTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let renderFailures = 0;
|
||||
// ENDLESS DREAM MODE — after the tour ends, fire a new random crazier figure
|
||||
// on this timer so the storm never sits idle. ($state so the template's
|
||||
// "∞ dreaming" indicator reacts when it starts/stops.)
|
||||
let dreamTimer = $state<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const reducedMotion =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
// ── INTERACTIVE PARALLAX ────────────────────────────────────────────────
|
||||
// Pointer/scroll/touch nudge the camera around the storm so the overlay feels
|
||||
// like a live toy, then converge back to 0 when idle so the scripted film is
|
||||
// never broken. None of this state is read in the template, so plain let/const
|
||||
// is correct (no $state needed). Frame-rate-independent easing via `damp`.
|
||||
const damp = (cur: number, tgt: number, lambda: number, dt: number) =>
|
||||
cur + (tgt - cur) * (1 - Math.exp(-lambda * dt));
|
||||
const pointer = { x: 0, y: 0 };
|
||||
const camOff = { yaw: 0, pitch: 0 };
|
||||
let zoomTarget = 0,
|
||||
zoomLive = 0;
|
||||
let lastInputAt = 0; // performance.now() of the last pointer/scroll/touch input
|
||||
const IDLE_MS = 2500,
|
||||
MAX_YAW = 0.35,
|
||||
MAX_PITCH = 0.22;
|
||||
const markInput = () => {
|
||||
lastInputAt = performance.now();
|
||||
};
|
||||
|
||||
// Deterministic layout: spread path nodes on a gentle spiral so the camera
|
||||
// has distinct world positions to fly between (independent of the WebGL
|
||||
// graph's internal coordinates — keeps the sandbox isolated).
|
||||
// Lay the beat nodes out on a TIGHT, BOUNDED shell centered on the origin —
|
||||
// fixed radius, no per-beat growth. Earlier this grew (22 + i*6) so each beat
|
||||
// sat farther out and the camera+storm marched off into space ("flying off").
|
||||
// A bounded shell keeps the whole composition centered; cinematic variety
|
||||
// comes from the camera angle/move/standoff, not from translating across a
|
||||
// huge volume. The focused node is always re-centered by recenterOn() below.
|
||||
const SHELL_RADIUS = 14;
|
||||
function layoutPositions(p: CinemaPath): Map<string, THREE.Vector3> {
|
||||
const pos = new Map<string, THREE.Vector3>();
|
||||
const n = p.beats.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
// Distribute beats evenly on a sphere (golden-angle spiral) so they
|
||||
// never clump and never exceed SHELL_RADIUS from center.
|
||||
const t = n > 1 ? i / (n - 1) : 0.5;
|
||||
const y = 1 - t * 2; // 1..-1
|
||||
const r = Math.sqrt(Math.max(0, 1 - y * y));
|
||||
const theta = i * 2.399963; // golden angle
|
||||
pos.set(
|
||||
p.beats[i].nodeId,
|
||||
new THREE.Vector3(
|
||||
Math.cos(theta) * r * SHELL_RADIUS,
|
||||
y * SHELL_RADIUS * 0.5,
|
||||
Math.sin(theta) * r * SHELL_RADIUS
|
||||
)
|
||||
);
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
|
||||
function speak(text: string) {
|
||||
if (!voiceOn || typeof speechSynthesis === 'undefined') return;
|
||||
try {
|
||||
speechSynthesis.cancel();
|
||||
const u = new SpeechSynthesisUtterance(text);
|
||||
u.rate = 0.98;
|
||||
u.pitch = 1.0;
|
||||
speechSynthesis.speak(u);
|
||||
} catch {
|
||||
/* voice unavailable — captions carry it */
|
||||
}
|
||||
}
|
||||
|
||||
// Typewriter caption stream so text "arrives" with the camera.
|
||||
function streamCaption(text: string) {
|
||||
if (typeTimer) clearInterval(typeTimer);
|
||||
caption = '';
|
||||
if (reducedMotion) {
|
||||
caption = text;
|
||||
return;
|
||||
}
|
||||
let i = 0;
|
||||
typeTimer = setInterval(() => {
|
||||
caption = text.slice(0, ++i);
|
||||
if (i >= text.length && typeTimer) {
|
||||
clearInterval(typeTimer);
|
||||
typeTimer = null;
|
||||
}
|
||||
}, 18);
|
||||
}
|
||||
|
||||
// Map the director's StormMode to the storm runtime's SemanticRole. 'surprise'
|
||||
// is a Phase-3 storm mode; until then it reads as 'connection'.
|
||||
function stormRole(mode: StormMode): SemanticRole {
|
||||
return mode === 'surprise' ? 'connection' : mode;
|
||||
}
|
||||
|
||||
function onBeat(beat: CinemaBeat, index: number, shot: ResolvedShot | null) {
|
||||
beatIndex = index + 1;
|
||||
const text = narration?.beats[index]?.text ?? beat.node.label ?? '';
|
||||
chip = narration?.beats[index]?.chip ?? '';
|
||||
streamCaption(text);
|
||||
speak(text);
|
||||
// Surface the director's intent for this shot — the "why", act, tension.
|
||||
if (shot) {
|
||||
directorNote = shot.why;
|
||||
act = shot.act;
|
||||
tension = shot.tension;
|
||||
}
|
||||
if (sandbox && webgpuActive) {
|
||||
const wp = currentPositions?.get(beat.nodeId);
|
||||
if (wp) {
|
||||
const mode: StormMode = shot?.stormMode ?? 'connection';
|
||||
// Pass act + 0-based beat index so the storm holds Act I dimmer AND
|
||||
// fades in extra-soft on beats 0/1 (which otherwise wash to white).
|
||||
sandbox.transitionTo(stormRole(mode), wp, shot?.act ?? 'I', index);
|
||||
}
|
||||
// Drive flythrough strength from beat energy: high-tension beats fly
|
||||
// THROUGH the storm (relaxed clamp + streak); reduced-motion = no streak.
|
||||
const energy = shot?.tension ?? 0;
|
||||
sandbox.setFlythrough(reducedMotion ? 0 : energy * 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
let currentPositions: Map<string, THREE.Vector3> | null = null;
|
||||
|
||||
async function launch() {
|
||||
// Tear down any prior run so Replay never inherits stale state.
|
||||
cancelAnimationFrame(rafId);
|
||||
stopDreamMode();
|
||||
if (typeTimer) clearInterval(typeTimer);
|
||||
director?.stop();
|
||||
sandbox?.dispose();
|
||||
sandbox = null;
|
||||
director = null;
|
||||
narration = null;
|
||||
renderFailures = 0;
|
||||
directorNote = '';
|
||||
logline = '';
|
||||
plan = null;
|
||||
act = 'I';
|
||||
tension = 0;
|
||||
|
||||
open = true;
|
||||
stage = 'planning';
|
||||
statusLine = 'Planning a path through your memory…';
|
||||
caption = '';
|
||||
chip = '';
|
||||
progress = 0;
|
||||
beatIndex = 0;
|
||||
|
||||
// Tier 3: plan the path (always works).
|
||||
path = planCinemaPath(nodes, edges, centerId, 7);
|
||||
totalBeats = path.beats.length;
|
||||
if (totalBeats === 0) {
|
||||
statusLine = 'Not enough memory to compose a tour yet.';
|
||||
stage = 'done';
|
||||
return;
|
||||
}
|
||||
currentPositions = layoutPositions(path);
|
||||
|
||||
// THE AUTEUR: read the graph's dramatic structure and direct the film.
|
||||
// Tier 2 (deterministic) ships the hero; Tier 1 (LLM) lands in Phase 4.
|
||||
const signals = computeSignals(nodes, edges);
|
||||
plan = planShotsDeterministic(path, signals);
|
||||
logline = plan.logline;
|
||||
const shots = resolveShots(plan, path);
|
||||
act = shots[0]?.act ?? 'I';
|
||||
tension = shots[0]?.tension ?? 0;
|
||||
|
||||
// Tiers 1/2: resolve narration (backend LLM → local captions).
|
||||
narration = await resolveNarration(path, localAiOn ? localAiFetcher() : fetchBackendNarration);
|
||||
narrationSource = narration.source;
|
||||
|
||||
// Try the WebGPU storm; fall back silently to camera + captions.
|
||||
webgpuActive = false;
|
||||
if (canvasHost) {
|
||||
try {
|
||||
const { CinemaSandbox, isWebGPUSupported } = await import('$lib/graph/cinema/sandbox');
|
||||
if (isWebGPUSupported()) {
|
||||
sandbox = new CinemaSandbox(canvasHost);
|
||||
await sandbox.boot();
|
||||
webgpuActive = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[cinema] WebGPU sandbox unavailable, camera-only mode:', e);
|
||||
sandbox = null;
|
||||
webgpuActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Director drives the camera (sandbox camera if WebGPU, else a virtual one).
|
||||
const fallbackAspect =
|
||||
canvasHost && canvasHost.clientHeight > 0
|
||||
? canvasHost.clientWidth / canvasHost.clientHeight
|
||||
: 16 / 9;
|
||||
const cam = sandbox?.cameraRef ?? new THREE.PerspectiveCamera(60, fallbackAspect, 0.1, 2000);
|
||||
const target = sandbox?.target ?? new THREE.Vector3();
|
||||
director = new CinemaDirector(cam, target, currentPositions, path, {
|
||||
onBeat,
|
||||
onProgress: (t) => (progress = t),
|
||||
onComplete: () => {
|
||||
stage = 'done';
|
||||
statusLine =
|
||||
reducedMotion || !webgpuActive
|
||||
? 'End of tour.'
|
||||
: '∞ Dreaming — endless generative figures';
|
||||
startDreamMode();
|
||||
},
|
||||
}, { reducedMotion, shots, centerOnOrigin: webgpuActive });
|
||||
|
||||
stage = 'playing';
|
||||
statusLine = webgpuActive
|
||||
? 'Rendering 150k-particle semantic storm on WebGPU…'
|
||||
: 'Cinematic flythrough (captions mode).';
|
||||
lastFrame = performance.now();
|
||||
director.start();
|
||||
loop();
|
||||
}
|
||||
|
||||
// BACK-PRESSURED FRAME LOOP. The WebGPU render of 150k particles + compute +
|
||||
// bloom can exceed a 16ms frame; the old loop fired requestAnimationFrame
|
||||
// IMMEDIATELY and never awaited render(), so rAF callbacks queued faster than
|
||||
// the GPU could drain them. Frames piled up → the camera (and the user's
|
||||
// parallax) visibly lagged behind the input. The fix: AWAIT the render, THEN
|
||||
// schedule the next frame. This caps the loop to the GPU's real throughput, so
|
||||
// every frame reflects the latest pointer position with no backlog.
|
||||
async function loop() {
|
||||
const now = performance.now();
|
||||
const dt = Math.max(0, Math.min(0.05, (now - lastFrame) / 1000));
|
||||
lastFrame = now;
|
||||
// The camera director is the bulletproof core — it must advance every
|
||||
// frame regardless of whether the WebGPU render succeeds.
|
||||
try {
|
||||
director?.update(dt);
|
||||
} catch (e) {
|
||||
console.warn('[cinema] director error:', e);
|
||||
}
|
||||
// Compose interactive parallax ON TOP of the director's camera. The user
|
||||
// nudges yaw/pitch/zoom; we ease toward 0 when idle so the scripted film is
|
||||
// seamless. sandbox.render() re-clamps distance + lookAt(origin), so the
|
||||
// user can never break the framing — this is purely additive sugar.
|
||||
if (!reducedMotion && sandbox && webgpuActive) {
|
||||
const cam = sandbox.cameraRef;
|
||||
const idle = performance.now() - lastInputAt > IDLE_MS;
|
||||
const yawT = idle ? 0 : pointer.x * MAX_YAW;
|
||||
const pitchT = idle ? 0 : pointer.y * MAX_PITCH;
|
||||
// Snappier response: faster damping while the user is actively steering
|
||||
// (lam 9 ≈ ~110ms to converge → feels immediate), gentle glide home when
|
||||
// idle (lam 1.5) so the handoff back to the film stays cinematic.
|
||||
const lam = idle ? 1.5 : 9.0;
|
||||
camOff.yaw = damp(camOff.yaw, yawT, lam, dt);
|
||||
camOff.pitch = damp(camOff.pitch, pitchT, lam, dt);
|
||||
if (idle) zoomTarget = damp(zoomTarget, 0, 1.2, dt);
|
||||
zoomLive = damp(zoomLive, zoomTarget, 8.0, dt);
|
||||
// Spherical offset around the director's current camera position
|
||||
// (target = origin).
|
||||
const dir = cam.position.clone();
|
||||
const sph = new THREE.Spherical().setFromVector3(dir);
|
||||
sph.theta += camOff.yaw;
|
||||
sph.phi = THREE.MathUtils.clamp(sph.phi + camOff.pitch, 0.2, Math.PI - 0.2);
|
||||
sph.radius *= 1 - zoomLive * 0.35;
|
||||
dir.setFromSpherical(sph);
|
||||
cam.position.copy(dir);
|
||||
}
|
||||
// Snapshot the sandbox so the async catch can't act on a sandbox that
|
||||
// close() nulled out while the render promise was in flight.
|
||||
const sb = sandbox;
|
||||
if (sb && webgpuActive) {
|
||||
try {
|
||||
await sb.render(dt); // ← AWAIT: back-pressure, no frame pile-up
|
||||
renderFailures = 0;
|
||||
} catch (e) {
|
||||
// A render failure must never stall the tour. After a few
|
||||
// consecutive failures, drop to camera-only (captions still play).
|
||||
if (++renderFailures >= 3 && sandbox === sb) {
|
||||
console.warn('[cinema] WebGPU render failing, dropping to camera-only:', e);
|
||||
webgpuActive = false;
|
||||
sb.dispose();
|
||||
sandbox = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Schedule the NEXT frame only after this one's render resolved.
|
||||
rafId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
// ── ENDLESS DREAM MODE ──────────────────────────────────────────────────
|
||||
// When the scripted tour ends, instead of freezing on the last figure, the
|
||||
// storm enters an infinite generative loop: every few seconds it morphs into
|
||||
// a fresh RANDOM procedural figure (supershape, torus-knot, lissajous, helix,
|
||||
// quantum foam) and detonates a color blast — each one crazier than the last.
|
||||
// The render loop() is already running, so we just fire dreamBeats on a timer.
|
||||
function startDreamMode() {
|
||||
if (reducedMotion || !sandbox || !webgpuActive) return; // honor reduced-motion
|
||||
stopDreamMode();
|
||||
// The endless dream gently flies THROUGH the procedural figures.
|
||||
if (!reducedMotion) sandbox?.setFlythrough(0.6);
|
||||
// Fire the first wild figure immediately, then keep going forever.
|
||||
sandbox?.dreamBeat();
|
||||
caption = '';
|
||||
chip = 'Dreaming';
|
||||
dreamTimer = setInterval(() => {
|
||||
// Sandbox may have been torn down (close / render-fail fallback).
|
||||
if (!sandbox || !webgpuActive) {
|
||||
stopDreamMode();
|
||||
return;
|
||||
}
|
||||
sandbox.dreamBeat();
|
||||
}, 5500); // a beat every ~5.5s — the blast flares then the figure settles
|
||||
// into its clean shape before the next detonation.
|
||||
}
|
||||
|
||||
function stopDreamMode() {
|
||||
if (dreamTimer) {
|
||||
clearInterval(dreamTimer);
|
||||
dreamTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
cancelAnimationFrame(rafId);
|
||||
stopDreamMode();
|
||||
if (typeTimer) clearInterval(typeTimer);
|
||||
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel();
|
||||
// Reset flythrough + parallax so a Replay never inherits stale offsets.
|
||||
if (sandbox) sandbox.setFlythrough?.(0);
|
||||
camOff.yaw = camOff.pitch = 0;
|
||||
zoomTarget = zoomLive = 0;
|
||||
director?.stop();
|
||||
sandbox?.dispose();
|
||||
sandbox = null;
|
||||
director = null;
|
||||
open = false;
|
||||
stage = 'idle';
|
||||
webgpuActive = false;
|
||||
}
|
||||
|
||||
// a11y: Escape closes the fullscreen overlay; the close button auto-focuses
|
||||
// on open so keyboard users land inside the dialog.
|
||||
let closeBtn = $state<HTMLButtonElement | undefined>(undefined);
|
||||
function onOverlayKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
close();
|
||||
} else if (e.key === 'h' || e.key === 'H') {
|
||||
// Toggle UI chrome for a clean demo capture (hide top bar + captions).
|
||||
e.preventDefault();
|
||||
cinemaChrome = !cinemaChrome;
|
||||
}
|
||||
}
|
||||
$effect(() => {
|
||||
if (open && closeBtn) closeBtn.focus();
|
||||
});
|
||||
|
||||
// Tag <body> while Cinema is open so the graph page can hide its bottom-stats
|
||||
// pill (which lives behind us in a separate stacking context). Cleaned up when
|
||||
// Cinema closes or the component unmounts.
|
||||
$effect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.body.classList.toggle('cinema-open', open);
|
||||
return () => document.body.classList.remove('cinema-open');
|
||||
});
|
||||
|
||||
// Interactive parallax listeners — only while the overlay is open, motion is
|
||||
// allowed, and the canvas host exists. Reduced-motion fully disables them
|
||||
// (no listeners attached at all). Cleanup removes everything on close/unmount.
|
||||
$effect(() => {
|
||||
if (!open || reducedMotion || !canvasHost) return;
|
||||
const host = canvasHost;
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
||||
host.style.touchAction = 'none';
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
pointer.x = (e.clientX / window.innerWidth) * 2 - 1;
|
||||
pointer.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
||||
markInput();
|
||||
};
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
zoomTarget = clamp(zoomTarget + e.deltaY * 0.0008, -1, 1);
|
||||
markInput();
|
||||
};
|
||||
let pinchPrev: number | null = null;
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (e.touches.length === 2) {
|
||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (pinchPrev !== null) {
|
||||
zoomTarget = clamp(zoomTarget + (dist - pinchPrev) * 0.002, -1, 1);
|
||||
}
|
||||
pinchPrev = dist;
|
||||
markInput();
|
||||
}
|
||||
};
|
||||
const onTouchEnd = () => {
|
||||
pinchPrev = null;
|
||||
};
|
||||
|
||||
host.addEventListener('pointermove', onPointerMove, { passive: true });
|
||||
host.addEventListener('wheel', onWheel, { passive: false });
|
||||
host.addEventListener('touchmove', onTouchMove, { passive: true });
|
||||
host.addEventListener('touchend', onTouchEnd);
|
||||
|
||||
return () => {
|
||||
host.removeEventListener('pointermove', onPointerMove);
|
||||
host.removeEventListener('wheel', onWheel);
|
||||
host.removeEventListener('touchmove', onTouchMove);
|
||||
host.removeEventListener('touchend', onTouchEnd);
|
||||
};
|
||||
});
|
||||
|
||||
// Opt-in on-device narration. Lazy-loads @huggingface/transformers ONLY when
|
||||
// the user enables "Local AI" and launches — never downloads a model
|
||||
// unprompted. Runs a small instruction model in-browser on WebGPU to rewrite
|
||||
// each beat's structured caption into richer prose. Returns null (→ Tier-2
|
||||
// local captions) ONLY if the package is absent or generation genuinely fails
|
||||
// — a real implementation with a real fallback, not a placeholder.
|
||||
type TransformersPipeline = (
|
||||
input: string,
|
||||
opts?: Record<string, unknown>
|
||||
) => Promise<Array<{ generated_text?: string }>>;
|
||||
function localAiFetcher(): () => Promise<BeatNarration[] | null> {
|
||||
return async () => {
|
||||
if (!path) return null;
|
||||
try {
|
||||
statusLine = 'Loading on-device model (first run downloads weights)…';
|
||||
// Computed specifier so TS/Vite don't resolve the optional,
|
||||
// un-bundled package at build time.
|
||||
const pkg = '@huggingface/transformers';
|
||||
const mod = (await import(/* @vite-ignore */ pkg).catch(() => null)) as {
|
||||
pipeline?: (task: string, model: string, opts?: Record<string, unknown>) => Promise<TransformersPipeline>;
|
||||
} | null;
|
||||
if (!mod?.pipeline) return null;
|
||||
|
||||
const generate = await mod.pipeline(
|
||||
'text-generation',
|
||||
'onnx-community/Qwen2.5-0.5B-Instruct',
|
||||
{ device: 'webgpu', dtype: 'q4' }
|
||||
);
|
||||
|
||||
// Seed from the deterministic local captions, then enrich each beat.
|
||||
const base = localCaptions(path);
|
||||
statusLine = 'Narrating with the on-device model…';
|
||||
const out: BeatNarration[] = [];
|
||||
for (const b of base.beats) {
|
||||
const prompt =
|
||||
`You are narrating a cinematic tour of an AI's memory graph. ` +
|
||||
`In one vivid sentence, narrate this beat: "${b.text}"`;
|
||||
const res = await generate(prompt, { max_new_tokens: 48, temperature: 0.7, do_sample: true });
|
||||
const text = res?.[0]?.generated_text?.replace(prompt, '').trim();
|
||||
out.push({ nodeId: b.nodeId, chip: b.chip, text: text && text.length > 4 ? text : b.text });
|
||||
}
|
||||
return out;
|
||||
} catch (e) {
|
||||
console.warn('[cinema] on-device narration failed, using local captions:', e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onDestroy(close);
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="cinema-launch glass rounded-full px-4 py-2 text-sm text-bright flex items-center gap-2 hover:scale-[1.03] transition"
|
||||
onclick={launch}
|
||||
aria-label="Start Memory Cinema — an AI-narrated flythrough of your memory"
|
||||
>
|
||||
<span aria-hidden="true">🎬</span> Memory Cinema
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="cinema-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Memory Cinema"
|
||||
tabindex="-1"
|
||||
onkeydown={onOverlayKeydown}
|
||||
>
|
||||
<div class="cinema-canvas" bind:this={canvasHost}></div>
|
||||
|
||||
<!-- Press H to hide all chrome for a clean demo capture. A tiny restore hint
|
||||
shows while hidden so it's never a trap. -->
|
||||
{#if !cinemaChrome}
|
||||
<button class="cinema-restore" onclick={() => (cinemaChrome = true)} title="Show UI (H)">
|
||||
press H to show UI
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if cinemaChrome}
|
||||
<!-- Top bar: status + close -->
|
||||
<div class="cinema-top glass-subtle">
|
||||
<div class="flex items-center gap-2 text-xs text-dim">
|
||||
<span class="cinema-dot" class:active={stage === 'playing'}></span>
|
||||
<span>{statusLine}</span>
|
||||
{#if plan}
|
||||
<span class="cinema-badge" title="Who directed this film">
|
||||
{plan.source === 'deterministic' ? 'Auteur (local)' : 'Auteur (AI)'}
|
||||
</span>
|
||||
{/if}
|
||||
{#if narrationSource}
|
||||
<span class="cinema-badge">{narrationSource === 'backend-llm' ? 'AI narration' : 'Live captions'}</span>
|
||||
{/if}
|
||||
{#if webgpuActive}<span class="cinema-badge cinema-badge-gpu">WebGPU</span>{/if}
|
||||
{#if stage === 'playing'}<span class="cinema-act">Act {act}</span>{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="cinema-toggle" title="Speak narration aloud">
|
||||
<input type="checkbox" bind:checked={voiceOn} /> Voice
|
||||
</label>
|
||||
<label class="cinema-toggle" title="Use an on-device model for narration (downloads weights on first use)">
|
||||
<input type="checkbox" bind:checked={localAiOn} /> Local AI
|
||||
</label>
|
||||
<button bind:this={closeBtn} class="cinema-close" onclick={close} aria-label="Close Memory Cinema (Esc)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pre-roll DIRECTOR'S PLAN card: the AI states its film before rolling. -->
|
||||
{#if stage === 'planning' && logline}
|
||||
<div class="cinema-plan-card glass-panel">
|
||||
<div class="cinema-plan-kicker">Director's plan</div>
|
||||
<p class="cinema-plan-logline">{logline}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Bottom: narration captions + director's note + progress -->
|
||||
<div class="cinema-caption-wrap">
|
||||
{#if chip}<div class="cinema-chip">{chip}</div>{/if}
|
||||
<p class="cinema-caption">{caption}</p>
|
||||
{#if directorNote && stage === 'playing'}
|
||||
<p class="cinema-note" title="Why the director chose this shot">▸ {directorNote}</p>
|
||||
{/if}
|
||||
<div class="cinema-progress" aria-hidden="true">
|
||||
<div
|
||||
class="cinema-progress-fill"
|
||||
style="width:{progress * 100}%; --tension:{tension}"
|
||||
></div>
|
||||
</div>
|
||||
<div class="cinema-beatcount text-dim text-xs">
|
||||
{#if stage === 'done' && dreamTimer}<span class="cinema-dream">∞ dreaming</span>
|
||||
{:else if totalBeats > 0}Beat {beatIndex} / {totalBeats}{/if}
|
||||
{#if stage === 'done'}<button class="cinema-replay" onclick={launch}>↻ Replay</button>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.cinema-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200; /* above the graph page's bottom-stats pill + all nav chrome */
|
||||
background: radial-gradient(circle at 50% 40%, #05050f 0%, #010108 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* While Cinema is open, hide the graph page's bottom-stats pill (it lives in a
|
||||
separate stacking context on the page behind us and would otherwise bleed
|
||||
through into a demo capture). Toggled by the .cinema-open class we add to
|
||||
<body> on mount. */
|
||||
:global(body.cinema-open .graph-stats-pill) {
|
||||
display: none;
|
||||
}
|
||||
.cinema-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
.cinema-top {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: max(0.75rem, env(safe-area-inset-top)) 1rem 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cinema-badge {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(129, 140, 248, 0.4);
|
||||
color: var(--color-synapse-glow);
|
||||
}
|
||||
.cinema-badge-gpu {
|
||||
border-color: rgba(20, 232, 198, 0.5);
|
||||
color: #14e8c6;
|
||||
}
|
||||
.cinema-act {
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-dream-glow);
|
||||
opacity: 0.85;
|
||||
}
|
||||
/* Pre-roll director's plan card — centered, the AI's statement of intent. */
|
||||
.cinema-plan-card {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
max-width: 520px;
|
||||
padding: 1.5rem 1.75rem;
|
||||
border-radius: 16px;
|
||||
text-align: center;
|
||||
animation: cinema-plan-in 0.5s ease both;
|
||||
}
|
||||
@keyframes cinema-plan-in {
|
||||
from { opacity: 0; transform: translate(-50%, -46%); }
|
||||
to { opacity: 1; transform: translate(-50%, -50%); }
|
||||
}
|
||||
.cinema-plan-kicker {
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-synapse-glow);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.cinema-plan-logline {
|
||||
font-size: clamp(1.05rem, 2.2vw, 1.4rem);
|
||||
line-height: 1.5;
|
||||
color: var(--color-bright);
|
||||
margin: 0;
|
||||
}
|
||||
.cinema-note {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-synapse-glow);
|
||||
opacity: 0.85;
|
||||
margin: 0 0 0.6rem;
|
||||
font-style: italic;
|
||||
}
|
||||
.cinema-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-muted);
|
||||
}
|
||||
.cinema-dot.active {
|
||||
background: #14e8c6;
|
||||
box-shadow: 0 0 10px #14e8c6;
|
||||
}
|
||||
.cinema-toggle {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cinema-close {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
color: var(--color-text);
|
||||
border-radius: 8px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cinema-caption-wrap {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-top: auto;
|
||||
padding: 1rem 1.25rem max(1.25rem, env(safe-area-inset-bottom));
|
||||
max-width: 720px;
|
||||
}
|
||||
.cinema-chip {
|
||||
display: inline-block;
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-dream-glow);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.cinema-caption {
|
||||
font-size: clamp(1.05rem, 2.4vw, 1.6rem);
|
||||
line-height: 1.45;
|
||||
color: var(--color-bright);
|
||||
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.9);
|
||||
min-height: 2.6em;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.cinema-progress {
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cinema-progress-fill {
|
||||
height: 100%;
|
||||
/* Tint shifts toward crimson as the shot's tension rises (--tension 0..1). */
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-synapse),
|
||||
color-mix(in oklch, var(--color-dream), #ff2d55 calc(var(--tension, 0) * 100%))
|
||||
);
|
||||
transition: width 0.2s linear, background 0.4s ease;
|
||||
}
|
||||
.cinema-beatcount {
|
||||
margin-top: 0.4rem;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
.cinema-replay {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(129, 140, 248, 0.4);
|
||||
color: var(--color-synapse-glow);
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.7rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.cinema-dream {
|
||||
color: var(--color-dream-glow);
|
||||
letter-spacing: 0.08em;
|
||||
animation: cinema-dream-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
/* Tiny restore hint shown while chrome is hidden (demo-capture mode). */
|
||||
.cinema-restore {
|
||||
position: absolute;
|
||||
bottom: 0.6rem;
|
||||
right: 0.8rem;
|
||||
z-index: 95;
|
||||
background: rgba(10, 10, 26, 0.5);
|
||||
border: 1px solid rgba(129, 140, 248, 0.25);
|
||||
color: var(--color-muted);
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.7rem;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.cinema-restore:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
@keyframes cinema-dream-pulse {
|
||||
0%, 100% { opacity: 0.55; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cinema-progress-fill {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
69
apps/dashboard/src/lib/components/PageHeader.svelte
Normal file
69
apps/dashboard/src/lib/components/PageHeader.svelte
Normal 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>
|
||||
204
apps/dashboard/src/lib/graph/cinema/__tests__/auteur.test.ts
Normal file
204
apps/dashboard/src/lib/graph/cinema/__tests__/auteur.test.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { planShotsDeterministic, resolveShots, SHOT_DEFAULTS, type DirectorPlan } from '../auteur';
|
||||
import { planCinemaPath } from '../pathfinder';
|
||||
import { computeSignals } from '../topology';
|
||||
import { makeNode, makeEdge, resetNodeCounter } from '../../__tests__/helpers';
|
||||
|
||||
describe('auteur — carry-forward shot resolution', () => {
|
||||
beforeEach(() => resetNodeCounter());
|
||||
|
||||
function smallPath() {
|
||||
const a = makeNode({ id: 'a' });
|
||||
const b = makeNode({ id: 'b' });
|
||||
const c = makeNode({ id: 'c' });
|
||||
const edges = [makeEdge('a', 'b', { weight: 0.8 }), makeEdge('b', 'c', { weight: 0.6 })];
|
||||
return { path: planCinemaPath([a, b, c], edges, 'a'), nodes: [a, b, c], edges };
|
||||
}
|
||||
|
||||
it('fills EVERY axis from a one-field shot, defaulting to today constants', () => {
|
||||
const { path } = smallPath();
|
||||
const plan: DirectorPlan = {
|
||||
source: 'backend-llm',
|
||||
logline: 'x',
|
||||
arc: 'flat',
|
||||
shots: [{ nodeId: 'a', move: 'orbit', why: 'test' }],
|
||||
};
|
||||
const resolved = resolveShots(plan, path);
|
||||
expect(resolved).toHaveLength(path.beats.length);
|
||||
// The specified field is honored…
|
||||
expect(resolved[0].move).toBe('orbit');
|
||||
// …and every other axis is a real default, never undefined.
|
||||
expect(resolved[0].standoff).toBe(SHOT_DEFAULTS.standoff);
|
||||
expect(resolved[0].flightSeconds).toBe(SHOT_DEFAULTS.flightSeconds);
|
||||
expect(resolved[0].angle).toBe('eye');
|
||||
for (const s of resolved) {
|
||||
for (const k of Object.keys(SHOT_DEFAULTS) as (keyof typeof SHOT_DEFAULTS)[]) {
|
||||
expect(s[k]).toBeDefined();
|
||||
}
|
||||
expect(typeof s.why).toBe('string');
|
||||
expect(s.why.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('carries non-cut axes forward to subsequent beats', () => {
|
||||
const { path } = smallPath();
|
||||
const plan: DirectorPlan = {
|
||||
source: 'backend-llm',
|
||||
logline: 'x',
|
||||
arc: 'flat',
|
||||
// Only the FIRST beat sets standoff; later beats should inherit it.
|
||||
shots: [{ nodeId: path.beats[0].nodeId, standoff: 41, why: 'set' }],
|
||||
};
|
||||
const resolved = resolveShots(plan, path);
|
||||
expect(resolved[0].standoff).toBe(41);
|
||||
expect(resolved[resolved.length - 1].standoff).toBe(41); // carried forward
|
||||
});
|
||||
|
||||
it('cut never carries forward — defaults to fly each beat', () => {
|
||||
const { path } = smallPath();
|
||||
const plan: DirectorPlan = {
|
||||
source: 'backend-llm',
|
||||
logline: 'x',
|
||||
arc: 'flat',
|
||||
shots: [{ nodeId: path.beats[0].nodeId, cut: 'hard_cut', why: 'cut' }],
|
||||
};
|
||||
const resolved = resolveShots(plan, path);
|
||||
expect(resolved[0].cut).toBe('hard_cut');
|
||||
if (resolved.length > 1) expect(resolved[1].cut).toBe('fly');
|
||||
});
|
||||
|
||||
it('back-fills garbage / out-of-range LLM fields from defaults', () => {
|
||||
const { path } = smallPath();
|
||||
const plan = {
|
||||
source: 'backend-llm',
|
||||
logline: 'x',
|
||||
arc: 'flat',
|
||||
shots: [
|
||||
{
|
||||
nodeId: path.beats[0].nodeId,
|
||||
move: 'teleport', // invalid enum
|
||||
standoff: 9999, // out of range
|
||||
dwellSeconds: -5, // out of range
|
||||
why: '',
|
||||
},
|
||||
],
|
||||
} as unknown as DirectorPlan;
|
||||
const resolved = resolveShots(plan, path);
|
||||
expect(resolved[0].move).toBe(SHOT_DEFAULTS.move); // invalid → default
|
||||
expect(resolved[0].standoff).toBeLessThanOrEqual(90); // clamped
|
||||
expect(resolved[0].dwellSeconds).toBeGreaterThanOrEqual(0.6); // clamped
|
||||
expect(resolved[0].why.length).toBeGreaterThan(0); // empty why → fallback
|
||||
});
|
||||
|
||||
it('a null plan still yields one default shot per beat', () => {
|
||||
const { path } = smallPath();
|
||||
const resolved = resolveShots(null, path);
|
||||
expect(resolved).toHaveLength(path.beats.length);
|
||||
expect(resolved[0].move).toBe(SHOT_DEFAULTS.move);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auteur — deterministic director', () => {
|
||||
beforeEach(() => resetNodeCounter());
|
||||
|
||||
it('produces a valid plan: one grounded shot per beat, every why non-empty, every nodeId real', () => {
|
||||
const nodes = Array.from({ length: 8 }, (_, i) => makeNode({ id: `n${i}` }));
|
||||
const edges = [
|
||||
makeEdge('n0', 'n1', { weight: 0.9 }),
|
||||
makeEdge('n1', 'n2', { weight: 0.2, type: 'contradiction' }),
|
||||
makeEdge('n0', 'n3', { weight: 0.5 }),
|
||||
makeEdge('n3', 'n4', { weight: 0.7 }),
|
||||
];
|
||||
const path = planCinemaPath(nodes, edges, 'n0');
|
||||
const signals = computeSignals(nodes, edges);
|
||||
const plan = planShotsDeterministic(path, signals);
|
||||
const realIds = new Set(nodes.map((n) => n.id));
|
||||
expect(plan.shots).toHaveLength(path.beats.length);
|
||||
for (const s of plan.shots) {
|
||||
expect(realIds.has(s.nodeId)).toBe(true);
|
||||
expect(s.why && s.why.length).toBeGreaterThan(0);
|
||||
}
|
||||
expect(plan.source).toBe('deterministic');
|
||||
expect(plan.logline.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('directs a contradiction beat as a Dutch hard-cut crimson collision', () => {
|
||||
const a = makeNode({ id: 'a' });
|
||||
const normal = makeNode({ id: 'normal' });
|
||||
const conflict = makeNode({ id: 'conflict' });
|
||||
const edges = [
|
||||
makeEdge('a', 'normal', { weight: 0.95 }),
|
||||
makeEdge('a', 'conflict', { weight: 0.2, type: 'contradiction' }),
|
||||
];
|
||||
const path = planCinemaPath([a, normal, conflict], edges, 'a');
|
||||
const signals = computeSignals([a, normal, conflict], edges);
|
||||
const plan = planShotsDeterministic(path, signals);
|
||||
const contradictionShot = plan.shots.find((_, i) => path.beats[i].kind === 'contradiction');
|
||||
expect(contradictionShot).toBeDefined();
|
||||
expect(contradictionShot!.stormMode).toBe('contradiction');
|
||||
expect(contradictionShot!.cut).toBe('hard_cut');
|
||||
expect(contradictionShot!.dutch).toBeGreaterThan(0);
|
||||
expect(contradictionShot!.scoreCue).toBe('minor_drop');
|
||||
});
|
||||
|
||||
it('ends on a crane pull-back with a major resolve', () => {
|
||||
const nodes = Array.from({ length: 5 }, (_, i) => makeNode({ id: `m${i}` }));
|
||||
const edges = nodes.slice(1).map((n, i) => makeEdge(`m${i}`, n.id, { weight: 0.6 }));
|
||||
const path = planCinemaPath(nodes, edges, 'm0');
|
||||
const signals = computeSignals(nodes, edges);
|
||||
const plan = planShotsDeterministic(path, signals);
|
||||
const last = plan.shots[plan.shots.length - 1];
|
||||
expect(last.move).toBe('crane');
|
||||
expect(last.scoreCue).toBe('major_resolve');
|
||||
});
|
||||
|
||||
it('is deterministic — same inputs yield the same plan', () => {
|
||||
const nodes = Array.from({ length: 6 }, (_, i) => makeNode({ id: `d${i}` }));
|
||||
const edges = [makeEdge('d0', 'd1', { weight: 0.8 }), makeEdge('d1', 'd2', { weight: 0.5 })];
|
||||
const path = planCinemaPath(nodes, edges, 'd0');
|
||||
const sig = computeSignals(nodes, edges);
|
||||
const p1 = planShotsDeterministic(path, sig);
|
||||
const p2 = planShotsDeterministic(path, sig);
|
||||
expect(p1.shots.map((s) => s.move)).toEqual(p2.shots.map((s) => s.move));
|
||||
expect(p1.logline).toBe(p2.logline);
|
||||
});
|
||||
});
|
||||
|
||||
describe('topology — graph signals', () => {
|
||||
beforeEach(() => resetNodeCounter());
|
||||
|
||||
it('computes betweenness, clusters, and peak keystone on a real shape', () => {
|
||||
// Two clusters bridged by 'hub' → hub has the highest betweenness.
|
||||
const hub = makeNode({ id: 'hub' });
|
||||
const l1 = makeNode({ id: 'l1' });
|
||||
const l2 = makeNode({ id: 'l2' });
|
||||
const r1 = makeNode({ id: 'r1' });
|
||||
const r2 = makeNode({ id: 'r2' });
|
||||
const edges = [
|
||||
makeEdge('l1', 'l2'),
|
||||
makeEdge('l2', 'hub'),
|
||||
makeEdge('hub', 'r1'),
|
||||
makeEdge('r1', 'r2'),
|
||||
];
|
||||
const sig = computeSignals([hub, l1, l2, r1, r2], edges);
|
||||
expect(sig.peakBetweennessId).toBe('hub');
|
||||
expect(sig.nodes.get('hub')!.betweenness).toBeGreaterThan(sig.nodes.get('l1')!.betweenness);
|
||||
expect(sig.clusterCount).toBe(1); // all connected through hub
|
||||
// All signals are finite and in range.
|
||||
for (const s of sig.nodes.values()) {
|
||||
expect(s.betweenness).toBeGreaterThanOrEqual(0);
|
||||
expect(s.betweenness).toBeLessThanOrEqual(1);
|
||||
expect(Number.isFinite(s.recencyRank)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('flags contradiction edges and computes surprise in range', () => {
|
||||
const a = makeNode({ id: 'a' });
|
||||
const b = makeNode({ id: 'b' });
|
||||
const edges = [makeEdge('a', 'b', { weight: 0.1, type: 'contradiction' })];
|
||||
const sig = computeSignals([a, b], edges);
|
||||
expect(sig.edges[0].isContradiction).toBe(true);
|
||||
expect(sig.edges[0].surprise).toBeGreaterThanOrEqual(0);
|
||||
expect(sig.edges[0].surprise).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { planCinemaPath } from '../pathfinder';
|
||||
import { makeNode, makeEdge, resetNodeCounter } from '../../__tests__/helpers';
|
||||
|
||||
describe('planCinemaPath', () => {
|
||||
beforeEach(() => resetNodeCounter());
|
||||
|
||||
it('returns an empty path for no nodes', () => {
|
||||
const path = planCinemaPath([], [], 'missing');
|
||||
expect(path.beats).toEqual([]);
|
||||
expect(path.flowEdges).toEqual([]);
|
||||
});
|
||||
|
||||
it('starts at the requested center when it exists', () => {
|
||||
const a = makeNode({ id: 'a' });
|
||||
const b = makeNode({ id: 'b' });
|
||||
const path = planCinemaPath([a, b], [makeEdge('a', 'b')], 'a');
|
||||
expect(path.beats[0].nodeId).toBe('a');
|
||||
expect(path.beats[0].kind).toBe('origin');
|
||||
expect(path.beats[0].viaEdge).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to the most-connected node when center is missing', () => {
|
||||
const hub = makeNode({ id: 'hub' });
|
||||
const x = makeNode({ id: 'x' });
|
||||
const y = makeNode({ id: 'y' });
|
||||
const path = planCinemaPath(
|
||||
[x, hub, y],
|
||||
[makeEdge('hub', 'x'), makeEdge('hub', 'y')],
|
||||
'does-not-exist'
|
||||
);
|
||||
expect(path.beats[0].nodeId).toBe('hub');
|
||||
});
|
||||
|
||||
it('visits the strongest-weighted connection first', () => {
|
||||
const a = makeNode({ id: 'a' });
|
||||
const weak = makeNode({ id: 'weak' });
|
||||
const strong = makeNode({ id: 'strong' });
|
||||
const path = planCinemaPath(
|
||||
[a, weak, strong],
|
||||
[makeEdge('a', 'weak', { weight: 0.1 }), makeEdge('a', 'strong', { weight: 0.9 })],
|
||||
'a'
|
||||
);
|
||||
expect(path.beats[1].nodeId).toBe('strong');
|
||||
expect(path.beats[1].kind).toBe('connection');
|
||||
});
|
||||
|
||||
it('detours through a contradiction edge when reachable', () => {
|
||||
const a = makeNode({ id: 'a' });
|
||||
const normal = makeNode({ id: 'normal' });
|
||||
const conflict = makeNode({ id: 'conflict' });
|
||||
const path = planCinemaPath(
|
||||
[a, normal, conflict],
|
||||
[
|
||||
makeEdge('a', 'normal', { weight: 0.95, type: 'semantic' }),
|
||||
makeEdge('a', 'conflict', { weight: 0.2, type: 'contradiction' }),
|
||||
],
|
||||
'a'
|
||||
);
|
||||
const kinds = path.beats.map((b) => b.kind);
|
||||
expect(kinds).toContain('contradiction');
|
||||
// The contradiction beat carries max intensity.
|
||||
const c = path.beats.find((b) => b.kind === 'contradiction');
|
||||
expect(c?.intensity).toBe(1);
|
||||
});
|
||||
|
||||
it('never exceeds maxBeats and never repeats a node', () => {
|
||||
const nodes = Array.from({ length: 20 }, (_, i) => makeNode({ id: `n${i}` }));
|
||||
const edges = nodes.slice(1).map((n) => makeEdge('n0', n.id, { weight: Math.random() }));
|
||||
const path = planCinemaPath(nodes, edges, 'n0', 5);
|
||||
expect(path.beats.length).toBeLessThanOrEqual(5);
|
||||
const ids = path.beats.map((b) => b.nodeId);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('is deterministic — same inputs yield the same path', () => {
|
||||
const nodes = [makeNode({ id: 'a' }), makeNode({ id: 'b' }), makeNode({ id: 'c' })];
|
||||
const edges = [makeEdge('a', 'b', { weight: 0.8 }), makeEdge('b', 'c', { weight: 0.6 })];
|
||||
const p1 = planCinemaPath(nodes, edges, 'a');
|
||||
const p2 = planCinemaPath(nodes, edges, 'a');
|
||||
expect(p1.beats.map((b) => b.nodeId)).toEqual(p2.beats.map((b) => b.nodeId));
|
||||
});
|
||||
|
||||
it('records flowEdges for each traversed connection', () => {
|
||||
const a = makeNode({ id: 'a' });
|
||||
const b = makeNode({ id: 'b' });
|
||||
const path = planCinemaPath([a, b], [makeEdge('a', 'b', { weight: 0.7 })], 'a');
|
||||
expect(path.flowEdges.length).toBeGreaterThanOrEqual(1);
|
||||
expect(path.flowEdges[0].source === 'a' || path.flowEdges[0].target === 'a').toBe(true);
|
||||
});
|
||||
});
|
||||
223
apps/dashboard/src/lib/graph/cinema/auteur.ts
Normal file
223
apps/dashboard/src/lib/graph/cinema/auteur.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
// The Auteur — the director's brain + the typed shot-plan contract.
|
||||
//
|
||||
// The LLM (Tier 1) or the deterministic rule table (Tier 2) produces a
|
||||
// DirectorPlan: a sequence of cinematographic Shots, one per CinemaBeat, each
|
||||
// grounded in a real node and justified by a real graph metric. The camera
|
||||
// runtime (director.ts) executes it. Carry-forward semantics mean a sparse or
|
||||
// half-hallucinated plan ALWAYS resolves to a coherent film — the same
|
||||
// robustness pattern as narrator.resolveNarration.
|
||||
|
||||
import type { CinemaPath, CinemaBeat } from './pathfinder';
|
||||
import type { GraphSignals } from './topology';
|
||||
|
||||
// ── Camera grammar (string unions keep LLM output validatable) ───────────────
|
||||
export type Move = 'push_in' | 'pull_back' | 'orbit' | 'crane' | 'whip_pan' | 'rack_focus' | 'hold';
|
||||
export type Angle = 'eye' | 'low' | 'high'; // low = look up (power); high = look down (decay)
|
||||
export type Cut = 'fly' | 'hard_cut' | 'match_cut';
|
||||
export type StormMode = 'anchor' | 'connection' | 'contradiction' | 'surprise';
|
||||
export type CaptionTone = 'curious' | 'tense' | 'resolved' | 'awe' | 'neutral';
|
||||
export type ScoreCue = 'motif' | 'minor_drop' | 'major_resolve' | 'silence';
|
||||
export type Act = 'I' | 'II' | 'III';
|
||||
export type EmotionalArc = 'man_in_hole' | 'rags_to_riches' | 'icarus' | 'cinderella' | 'oedipus' | 'flat';
|
||||
export type DirectorSource = 'backend-llm' | 'on-device' | 'deterministic';
|
||||
|
||||
/** A directed shot. Only axes that CHANGE need be set — the rest carry forward
|
||||
* from the previous resolved shot (ultimate default = today's camera constants). */
|
||||
export interface Shot {
|
||||
nodeId: string; // MUST cite a real node (alignment key + grounding constraint)
|
||||
move?: Move;
|
||||
angle?: Angle;
|
||||
dutch?: number; // camera roll, radians, 0..~0.5
|
||||
standoff?: number; // world units
|
||||
flightSeconds?: number;
|
||||
dwellSeconds?: number;
|
||||
halflife?: number; // spring smoothing; 0 = jump-cut
|
||||
cut?: Cut;
|
||||
stormMode?: StormMode;
|
||||
intensity?: number; // 0..1 → scales the ignition spike
|
||||
tension?: number; // 0..1 master scalar
|
||||
act?: Act;
|
||||
tone?: CaptionTone;
|
||||
scoreCue?: ScoreCue;
|
||||
why: string; // REQUIRED: cites the real metric driving this shot
|
||||
viaEdgeKey?: string; // `${source}->${target}` for two-node framing
|
||||
}
|
||||
|
||||
export interface DirectorPlan {
|
||||
source: DirectorSource;
|
||||
logline: string;
|
||||
arc: EmotionalArc;
|
||||
shots: Shot[];
|
||||
}
|
||||
|
||||
/** Every axis filled after carry-forward — what the director reads each beat. */
|
||||
export type ResolvedShot = Required<Omit<Shot, 'viaEdgeKey'>> & { viaEdgeKey?: string };
|
||||
|
||||
// Ultimate defaults — today's hardcoded camera constants, so a plan-less or
|
||||
// fully-sparse run is byte-identical to the pre-Auteur camera.
|
||||
export const SHOT_DEFAULTS: Omit<ResolvedShot, 'nodeId' | 'why'> = {
|
||||
move: 'hold',
|
||||
angle: 'eye',
|
||||
dutch: 0,
|
||||
standoff: 26,
|
||||
flightSeconds: 2.4,
|
||||
dwellSeconds: 3.2,
|
||||
halflife: 0.35,
|
||||
cut: 'fly',
|
||||
stormMode: 'connection',
|
||||
intensity: 0.7,
|
||||
tension: 0.3,
|
||||
act: 'I',
|
||||
tone: 'neutral',
|
||||
scoreCue: 'motif',
|
||||
};
|
||||
|
||||
const MOVES: ReadonlySet<Move> = new Set(['push_in', 'pull_back', 'orbit', 'crane', 'whip_pan', 'rack_focus', 'hold']);
|
||||
const ANGLES: ReadonlySet<Angle> = new Set(['eye', 'low', 'high']);
|
||||
const CUTS: ReadonlySet<Cut> = new Set(['fly', 'hard_cut', 'match_cut']);
|
||||
const STORM_MODES: ReadonlySet<StormMode> = new Set(['anchor', 'connection', 'contradiction', 'surprise']);
|
||||
const TONES: ReadonlySet<CaptionTone> = new Set(['curious', 'tense', 'resolved', 'awe', 'neutral']);
|
||||
const SCORE_CUES: ReadonlySet<ScoreCue> = new Set(['motif', 'minor_drop', 'major_resolve', 'silence']);
|
||||
const ACTS: ReadonlySet<Act> = new Set(['I', 'II', 'III']);
|
||||
|
||||
function num(v: unknown, lo: number, hi: number, fallback: number): number {
|
||||
const n = typeof v === 'number' && Number.isFinite(v) ? v : NaN;
|
||||
if (Number.isNaN(n)) return fallback;
|
||||
return Math.max(lo, Math.min(hi, n));
|
||||
}
|
||||
function pick<T>(v: unknown, set: ReadonlySet<T>, fallback: T): T {
|
||||
return typeof v === 'string' && set.has(v as T) ? (v as T) : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a DirectorPlan into one fully-specified ResolvedShot per beat.
|
||||
* Aligns by nodeId; every unspecified/garbage axis is back-filled by carry-forward
|
||||
* (previous shot → SHOT_DEFAULTS). A shot can NEVER be blank or invalid.
|
||||
*/
|
||||
export function resolveShots(plan: DirectorPlan | null, path: CinemaPath): ResolvedShot[] {
|
||||
const byNode = new Map<string, Shot>();
|
||||
for (const s of plan?.shots ?? []) {
|
||||
if (s && typeof s.nodeId === 'string') byNode.set(s.nodeId, s);
|
||||
}
|
||||
const resolved: ResolvedShot[] = [];
|
||||
let prev: ResolvedShot | null = null;
|
||||
for (const beat of path.beats) {
|
||||
const raw = byNode.get(beat.nodeId);
|
||||
const base = prev ?? { ...SHOT_DEFAULTS, nodeId: beat.nodeId, why: '' };
|
||||
const shot: ResolvedShot = {
|
||||
nodeId: beat.nodeId,
|
||||
move: pick(raw?.move, MOVES, base.move),
|
||||
angle: pick(raw?.angle, ANGLES, base.angle),
|
||||
dutch: num(raw?.dutch, 0, 0.6, base.dutch),
|
||||
standoff: num(raw?.standoff, 8, 90, base.standoff),
|
||||
flightSeconds: num(raw?.flightSeconds, 0.4, 6, base.flightSeconds),
|
||||
dwellSeconds: num(raw?.dwellSeconds, 0.6, 8, base.dwellSeconds),
|
||||
halflife: num(raw?.halflife, 0, 1.5, base.halflife),
|
||||
cut: pick(raw?.cut, CUTS, 'fly'), // cut never carries forward — default per beat
|
||||
stormMode: pick(raw?.stormMode, STORM_MODES, base.stormMode),
|
||||
intensity: num(raw?.intensity, 0, 1, base.intensity),
|
||||
tension: num(raw?.tension, 0, 1, base.tension),
|
||||
act: pick(raw?.act, ACTS, base.act),
|
||||
tone: pick(raw?.tone, TONES, base.tone),
|
||||
scoreCue: pick(raw?.scoreCue, SCORE_CUES, 'motif'),
|
||||
why: typeof raw?.why === 'string' && raw.why.trim() ? raw.why : base.why || 'establishing shot',
|
||||
viaEdgeKey: typeof raw?.viaEdgeKey === 'string' ? raw.viaEdgeKey : undefined,
|
||||
};
|
||||
resolved.push(shot);
|
||||
prev = shot;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// ── The deterministic auteur (Tier 2) ────────────────────────────────────────
|
||||
// The graph-metric → shot-grammar rule table. This SAME table is handed to the
|
||||
// LLM as its system prompt (see directorSystemPrompt), so Tier-1 output is
|
||||
// directly comparable to and back-fillable against this baseline.
|
||||
|
||||
function actFor(progress: number): Act {
|
||||
return progress < 0.34 ? 'I' : progress < 0.72 ? 'II' : 'III';
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a cinematic DirectorPlan from pure graph signals — no LLM. This alone
|
||||
* ships the hero film: every shot is grounded and justified by a real metric.
|
||||
*/
|
||||
export function planShotsDeterministic(path: CinemaPath, signals: GraphSignals): DirectorPlan {
|
||||
const n = path.beats.length;
|
||||
const shots: Shot[] = path.beats.map((beat, i) => {
|
||||
const progress = n > 1 ? i / (n - 1) : 0;
|
||||
const act = actFor(progress);
|
||||
const sig = signals.nodes.get(beat.nodeId);
|
||||
const isPeak = beat.nodeId === signals.peakBetweennessId;
|
||||
const isFinale = i === n - 1;
|
||||
const isOrigin = i === 0;
|
||||
|
||||
// Default shot for a plain connection beat.
|
||||
let shot: Shot = {
|
||||
nodeId: beat.nodeId,
|
||||
move: 'push_in',
|
||||
angle: 'eye',
|
||||
cut: 'fly',
|
||||
stormMode: 'connection',
|
||||
tone: 'curious',
|
||||
scoreCue: 'motif',
|
||||
act,
|
||||
intensity: 0.6,
|
||||
tension: 0.3,
|
||||
why: 'a connected memory',
|
||||
};
|
||||
|
||||
if (isOrigin) {
|
||||
shot = { ...shot, move: 'push_in', tone: 'curious', tension: 0.25, stormMode: 'anchor', why: 'opening on the focal memory' };
|
||||
}
|
||||
// High-betweenness keystone → reverent low-angle slow orbit.
|
||||
if (isPeak || (sig && sig.betweenness > 0.6)) {
|
||||
shot = { ...shot, move: 'orbit', angle: 'low', stormMode: 'anchor', intensity: 0.75, tension: 0.45, tone: 'awe', why: 'low-angle orbit — the most load-bearing memory in the graph' };
|
||||
}
|
||||
// Contradiction → Dutch push-in, hard cut, crimson chaos, minor drop.
|
||||
if (beat.kind === 'contradiction') {
|
||||
shot = { ...shot, move: 'push_in', angle: 'eye', dutch: 0.28, cut: 'hard_cut', stormMode: 'contradiction', intensity: 1, tension: 0.95, tone: 'tense', scoreCue: 'minor_drop', viaEdgeKey: beat.viaEdge ? `${beat.viaEdge.source}->${beat.viaEdge.target}` : undefined, why: 'two memories in tension — a Dutch two-shot collision' };
|
||||
}
|
||||
// Surprise edge → gold/violet convergence, rising awe.
|
||||
if (beat.kind === 'surprise') {
|
||||
shot = { ...shot, move: 'orbit', stormMode: 'surprise', intensity: 0.85, tension: 0.6, tone: 'awe', scoreCue: 'motif', why: 'a surprising, distant-but-plausible connection' };
|
||||
}
|
||||
// Fading memory → drifting high angle.
|
||||
if (sig && (sig.retention < 0.35 || sig.suppression > 0.5)) {
|
||||
shot = { ...shot, angle: 'high', move: 'pull_back', tone: 'neutral', intensity: 0.4, why: 'a fading memory — high-angle drift' };
|
||||
}
|
||||
// Recent → the "now" beat.
|
||||
if (beat.kind === 'recent') {
|
||||
shot = { ...shot, move: 'push_in', tone: 'resolved', tension: 0.4, why: 'where the memory is now' };
|
||||
}
|
||||
// Finale → crane pull-back, major resolve.
|
||||
if (isFinale) {
|
||||
shot = { ...shot, move: 'crane', cut: 'fly', stormMode: 'anchor', tone: 'awe', tension: 0.5, scoreCue: 'major_resolve', why: 'crane pull-back over the whole cluster — resolution' };
|
||||
}
|
||||
return shot;
|
||||
});
|
||||
|
||||
const arc: EmotionalArc = path.beats.some((b) => b.kind === 'contradiction') ? 'man_in_hole' : 'rags_to_riches';
|
||||
const originLabel = path.beats[0]?.node.label ?? 'a memory';
|
||||
const logline = `A short film about ${originLabel} — ${n} shots through the graph${arc === 'man_in_hole' ? ', through a contradiction and out the other side' : ''}.`;
|
||||
|
||||
return { source: 'deterministic', logline, arc, shots };
|
||||
}
|
||||
|
||||
/** The rule table as an LLM system prompt — keeps Tier-1 output comparable to
|
||||
* the Tier-2 baseline (and thus back-fillable by resolveShots). */
|
||||
export function directorSystemPrompt(): string {
|
||||
return [
|
||||
'You are a film director shooting a short documentary about an AI\'s own memory graph.',
|
||||
'Output a DirectorPlan: a logline, an emotional arc, and one shot per beat.',
|
||||
'Each shot MUST cite a real nodeId and a real "why" referencing a graph metric.',
|
||||
'Grammar → meaning:',
|
||||
'- high betweenness (load-bearing memory) → low-angle slow orbit, reverent',
|
||||
'- contradiction edge → Dutch angle + push_in + hard_cut + crimson storm + minor_drop score',
|
||||
'- surprising distant link → gold/violet orbit→stream convergence + awe',
|
||||
'- merge/supersede → match_cut at identical standoff+angle (same idea)',
|
||||
'- low retention / high suppression → high-angle drift (fading)',
|
||||
'- finale → crane pull_back + major_resolve',
|
||||
'Build a real emotional arc across acts I→II→III. Only specify axes that change.',
|
||||
].join('\n');
|
||||
}
|
||||
285
apps/dashboard/src/lib/graph/cinema/director.ts
Normal file
285
apps/dashboard/src/lib/graph/cinema/director.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
// Memory Cinema — the camera director.
|
||||
//
|
||||
// Drives a smooth, cinematic camera flight through a planned CinemaPath. Pure
|
||||
// choreography: it mutates a THREE.PerspectiveCamera + an OrbitControls-like
|
||||
// target each frame and emits beat-arrival callbacks the narrator + sandbox
|
||||
// hook into. It knows nothing about which renderer (WebGL/WebGPU) is on screen,
|
||||
// so it works identically for the legacy graph and the WebGPU sandbox.
|
||||
//
|
||||
// Respects prefers-reduced-motion: when reduced, it JUMP-CUTS between beats
|
||||
// (instant position, dwell, advance) instead of flying — captions still fire.
|
||||
|
||||
import * as THREE from 'three';
|
||||
import type { CinemaPath, CinemaBeat } from './pathfinder';
|
||||
import type { ResolvedShot } from './auteur';
|
||||
|
||||
export interface DirectorCallbacks {
|
||||
/** Fired once when the camera arrives at (or cuts to) a beat. The resolved
|
||||
* shot for the beat is passed so consumers can drive storm/score/captions. */
|
||||
onBeat?: (beat: CinemaBeat, index: number, shot: ResolvedShot | null) => void;
|
||||
/** Fired when the whole tour finishes. */
|
||||
onComplete?: () => void;
|
||||
/** Fired every frame with overall progress 0..1 (for a scrubber/progress bar). */
|
||||
onProgress?: (t: number) => void;
|
||||
}
|
||||
|
||||
export interface DirectorOptions {
|
||||
/** Seconds of camera flight between consecutive beats. */
|
||||
flightSeconds?: number;
|
||||
/** Seconds the camera dwells on each beat before advancing. */
|
||||
dwellSeconds?: number;
|
||||
/** Stand-off distance from the focused node, in world units. */
|
||||
standoff?: number;
|
||||
/** Instant cuts instead of flights (prefers-reduced-motion). */
|
||||
reducedMotion?: boolean;
|
||||
/** Optional per-beat director's plan (one ResolvedShot per beat, aligned by
|
||||
* index). When ABSENT the camera behaves byte-identically to the pre-Auteur
|
||||
* director — every value falls back to the constants above. When present,
|
||||
* each shot's move/angle/dutch/standoff/flight/dwell/cut directs that beat. */
|
||||
shots?: ResolvedShot[];
|
||||
/** When true, the camera frames the WORLD ORIGIN every shot (the WebGPU storm
|
||||
* is pinned there) instead of flying out to scattered node positions — so the
|
||||
* subject is ALWAYS centered and can never fly off-screen. Camera variety
|
||||
* comes purely from angle/standoff/orbit. Used by the WebGPU sandbox path. */
|
||||
centerOnOrigin?: boolean;
|
||||
}
|
||||
|
||||
type Phase = 'idle' | 'flying' | 'dwelling' | 'done';
|
||||
|
||||
const _tmpDir = new THREE.Vector3();
|
||||
const _tmpUp = new THREE.Vector3(0, 1, 0);
|
||||
const _origin = new THREE.Vector3(0, 0, 0);
|
||||
|
||||
export class CinemaDirector {
|
||||
private camera: THREE.PerspectiveCamera;
|
||||
private target: THREE.Vector3;
|
||||
private positions: Map<string, THREE.Vector3>;
|
||||
private path: CinemaPath;
|
||||
private cb: DirectorCallbacks;
|
||||
private opts: Required<DirectorOptions>;
|
||||
|
||||
private phase: Phase = 'idle';
|
||||
private beatIndex = 0;
|
||||
private phaseElapsed = 0;
|
||||
|
||||
// Flight interpolation endpoints.
|
||||
private fromPos = new THREE.Vector3();
|
||||
private toPos = new THREE.Vector3();
|
||||
private fromTarget = new THREE.Vector3();
|
||||
private toTarget = new THREE.Vector3();
|
||||
|
||||
constructor(
|
||||
camera: THREE.PerspectiveCamera,
|
||||
target: THREE.Vector3,
|
||||
positions: Map<string, THREE.Vector3>,
|
||||
path: CinemaPath,
|
||||
cb: DirectorCallbacks = {},
|
||||
opts: DirectorOptions = {}
|
||||
) {
|
||||
this.camera = camera;
|
||||
this.target = target;
|
||||
this.positions = positions;
|
||||
this.path = path;
|
||||
this.cb = cb;
|
||||
this.opts = {
|
||||
flightSeconds: opts.flightSeconds ?? 2.4,
|
||||
dwellSeconds: opts.dwellSeconds ?? 3.2,
|
||||
standoff: opts.standoff ?? 26,
|
||||
reducedMotion: opts.reducedMotion ?? false,
|
||||
shots: opts.shots ?? [],
|
||||
centerOnOrigin: opts.centerOnOrigin ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/** The resolved shot directing a beat, or null when no plan was supplied
|
||||
* (→ the camera uses the constant defaults = pre-Auteur behavior). */
|
||||
private shotAt(index: number): ResolvedShot | null {
|
||||
return this.opts.shots[index] ?? null;
|
||||
}
|
||||
|
||||
/** Per-beat flight duration: the shot's value, else the global default. A
|
||||
* hard/match cut has zero flight (handled in beginFlightTo). */
|
||||
private flightSecondsAt(index: number): number {
|
||||
return this.shotAt(index)?.flightSeconds ?? this.opts.flightSeconds;
|
||||
}
|
||||
private dwellSecondsAt(index: number): number {
|
||||
return this.shotAt(index)?.dwellSeconds ?? this.opts.dwellSeconds;
|
||||
}
|
||||
|
||||
get totalBeats(): number {
|
||||
return this.path.beats.length;
|
||||
}
|
||||
|
||||
get isRunning(): boolean {
|
||||
return this.phase !== 'idle' && this.phase !== 'done';
|
||||
}
|
||||
|
||||
/** Begin the tour from the first beat. */
|
||||
start(): void {
|
||||
if (this.path.beats.length === 0) {
|
||||
this.phase = 'done';
|
||||
this.cb.onComplete?.();
|
||||
return;
|
||||
}
|
||||
this.beatIndex = 0;
|
||||
this.beginFlightTo(0);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.phase = 'done';
|
||||
}
|
||||
|
||||
/** The focal point a beat frames: the world ORIGIN in centered mode (storm is
|
||||
* pinned there), else the node's laid-out position. */
|
||||
private focalPoint(beat: CinemaBeat): THREE.Vector3 | null {
|
||||
if (this.opts.centerOnOrigin) return _origin;
|
||||
return this.positions.get(beat.nodeId) ?? null;
|
||||
}
|
||||
|
||||
/** Compute the camera stand-off position for a beat's focal point, directed by
|
||||
* its shot (move / angle / standoff). With no shot, reproduces the original
|
||||
* framing exactly: standoff = opts.standoff, +0.35 up-bias (filmic tilt). */
|
||||
private framePosition(beat: CinemaBeat, index: number, out: THREE.Vector3): THREE.Vector3 {
|
||||
const nodePos = this.focalPoint(beat);
|
||||
if (!nodePos) {
|
||||
// Node has no resolved position yet — keep current framing.
|
||||
return out.copy(this.camera.position);
|
||||
}
|
||||
const shot = this.shotAt(index);
|
||||
|
||||
_tmpDir.copy(this.camera.position).sub(nodePos);
|
||||
if (_tmpDir.lengthSq() < 1e-4) _tmpDir.set(0, 0.4, 1);
|
||||
_tmpDir.normalize();
|
||||
|
||||
// Vertical bias = the camera angle. Default +0.35 (slightly above, the
|
||||
// original filmic tilt). low = look UP at the node (power), high = look
|
||||
// DOWN (decay/fading).
|
||||
let upBias = 0.35;
|
||||
if (shot) {
|
||||
if (shot.angle === 'low') upBias = -0.45;
|
||||
else if (shot.angle === 'high') upBias = 0.7;
|
||||
}
|
||||
_tmpDir.addScaledVector(_tmpUp, upBias).normalize();
|
||||
|
||||
// Stand-off = how close: push_in tightens, pull_back/crane widen.
|
||||
let standoff = shot?.standoff ?? this.opts.standoff;
|
||||
if (shot) {
|
||||
if (shot.move === 'push_in') standoff *= 0.7;
|
||||
else if (shot.move === 'pull_back') standoff *= 1.5;
|
||||
else if (shot.move === 'crane') standoff *= 1.8;
|
||||
}
|
||||
// In centered (WebGPU storm) mode the subject is pinned to the origin and
|
||||
// the sandbox clamps the camera to a far band. Keep the directed standoff
|
||||
// INSIDE that band so the camera never fights the clamp (which read as an
|
||||
// off-center jump) — variety here comes from angle + orbit, not distance.
|
||||
if (this.opts.centerOnOrigin) standoff = Math.max(31, Math.min(43, standoff));
|
||||
return out.copy(nodePos).addScaledVector(_tmpDir, standoff);
|
||||
}
|
||||
|
||||
private beginFlightTo(index: number): void {
|
||||
const beat = this.path.beats[index];
|
||||
const nodePos = this.focalPoint(beat);
|
||||
const shot = this.shotAt(index);
|
||||
|
||||
this.fromPos.copy(this.camera.position);
|
||||
this.fromTarget.copy(this.target);
|
||||
this.framePosition(beat, index, this.toPos);
|
||||
this.toTarget.copy(nodePos ?? this.target);
|
||||
this.phaseElapsed = 0;
|
||||
|
||||
// A directed hard/match cut snaps instantly (like reduced-motion), so the
|
||||
// editorial "cut" reads as an edit, not a fly. reduced-motion forces this
|
||||
// for every beat regardless of shot.
|
||||
const snap = this.opts.reducedMotion || shot?.cut === 'hard_cut' || shot?.cut === 'match_cut';
|
||||
if (snap) {
|
||||
this.camera.position.copy(this.toPos);
|
||||
this.target.copy(this.toTarget);
|
||||
this.phase = 'dwelling';
|
||||
this.cb.onBeat?.(beat, index, shot);
|
||||
} else {
|
||||
this.phase = 'flying';
|
||||
}
|
||||
}
|
||||
|
||||
/** Advance the choreography. Call once per animation frame with delta seconds. */
|
||||
update(deltaSeconds: number): void {
|
||||
if (this.phase === 'idle' || this.phase === 'done') return;
|
||||
// Clamp dt so a tab-switch stall doesn't teleport the camera.
|
||||
const dt = Math.max(0, Math.min(deltaSeconds, 0.05));
|
||||
this.phaseElapsed += dt;
|
||||
|
||||
const flightSecs = this.flightSecondsAt(this.beatIndex);
|
||||
const dwellSecs = this.dwellSecondsAt(this.beatIndex);
|
||||
|
||||
if (this.phase === 'flying') {
|
||||
const t = Math.min(1, this.phaseElapsed / flightSecs);
|
||||
const e = easeInOutCubic(t);
|
||||
this.camera.position.lerpVectors(this.fromPos, this.toPos, e);
|
||||
this.target.lerpVectors(this.fromTarget, this.toTarget, e);
|
||||
this.applyDutch(this.beatIndex, e);
|
||||
if (t >= 1) {
|
||||
this.phase = 'dwelling';
|
||||
this.phaseElapsed = 0;
|
||||
this.cb.onBeat?.(this.path.beats[this.beatIndex], this.beatIndex, this.shotAt(this.beatIndex));
|
||||
}
|
||||
} else if (this.phase === 'dwelling') {
|
||||
if (!this.opts.reducedMotion) {
|
||||
const nodePos = this.focalPoint(this.path.beats[this.beatIndex]);
|
||||
if (nodePos) {
|
||||
this.target.lerp(nodePos, 0.02); // gentle settle keeps the shot alive
|
||||
// An orbit shot slowly revolves the camera around the node
|
||||
// during the dwell — the signature "reverent" move for keystones.
|
||||
if (this.shotAt(this.beatIndex)?.move === 'orbit') {
|
||||
this.orbitAround(nodePos, dt * 0.35);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.phaseElapsed >= dwellSecs) {
|
||||
const nextIndex = this.beatIndex + 1;
|
||||
if (nextIndex >= this.path.beats.length) {
|
||||
this.phase = 'done';
|
||||
this.cb.onProgress?.(1);
|
||||
this.cb.onComplete?.();
|
||||
return;
|
||||
}
|
||||
this.beatIndex = nextIndex;
|
||||
this.beginFlightTo(nextIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// Overall progress across the whole tour (beat + intra-beat fraction).
|
||||
// Guard against an empty path (per = 0) so progress can never be NaN.
|
||||
const per = this.path.beats.length > 0 ? 1 / this.path.beats.length : 0;
|
||||
const intra =
|
||||
this.phase === 'flying'
|
||||
? Math.min(1, this.phaseElapsed / flightSecs) * 0.5
|
||||
: 0.5 + Math.min(1, this.phaseElapsed / dwellSecs) * 0.5;
|
||||
this.cb.onProgress?.(Math.min(1, this.beatIndex * per + intra * per));
|
||||
}
|
||||
|
||||
/** Revolve the camera around a node by `angle` radians (orbit shots). */
|
||||
private orbitAround(center: THREE.Vector3, angle: number): void {
|
||||
_tmpDir.copy(this.camera.position).sub(center);
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
const x = _tmpDir.x * cos - _tmpDir.z * sin;
|
||||
const z = _tmpDir.x * sin + _tmpDir.z * cos;
|
||||
_tmpDir.x = x;
|
||||
_tmpDir.z = z;
|
||||
this.camera.position.copy(center).add(_tmpDir);
|
||||
}
|
||||
|
||||
/** Roll the camera (Dutch angle) toward the shot's target roll over the
|
||||
* flight, easing back to upright for non-Dutch shots. */
|
||||
private applyDutch(index: number, t: number): void {
|
||||
const targetRoll = this.shotAt(index)?.dutch ?? 0;
|
||||
const roll = targetRoll * t;
|
||||
// camera.up = rotate world-up around the camera's forward axis by `roll`.
|
||||
_tmpDir.set(0, 0, -1).applyQuaternion(this.camera.quaternion); // forward
|
||||
this.camera.up.set(0, 1, 0).applyAxisAngle(_tmpDir, roll);
|
||||
}
|
||||
}
|
||||
|
||||
function easeInOutCubic(t: number): number {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
}
|
||||
148
apps/dashboard/src/lib/graph/cinema/narrator.ts
Normal file
148
apps/dashboard/src/lib/graph/cinema/narrator.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// Memory Cinema — narration tiers 1 & 2.
|
||||
//
|
||||
// Tier 1 (premium): a backend LLM endpoint (/api/narrative) authors rich prose
|
||||
// from the planned path. Used only when the backend advertises it.
|
||||
// Tier 2 (smart local default): deterministic, structured captions generated
|
||||
// purely from the real node/edge data — no network, no LLM, instant. This is
|
||||
// what the static HN demo and any backend-without-LLM setup uses.
|
||||
//
|
||||
// Tier 3 (the BFS camera engine in director.ts) always runs underneath; the
|
||||
// narrator only decides what TEXT accompanies each beat. If everything here
|
||||
// fails, captions fall back to Tier 2, which cannot fail.
|
||||
|
||||
import type { CinemaBeat, CinemaPath } from './pathfinder';
|
||||
|
||||
export interface BeatNarration {
|
||||
nodeId: string;
|
||||
/** The caption shown + optionally spoken for this beat. */
|
||||
text: string;
|
||||
/** Short label for the beat kind, shown as a chip. */
|
||||
chip: string;
|
||||
}
|
||||
|
||||
export type NarrationSource = 'backend-llm' | 'local-captions';
|
||||
|
||||
export interface CinemaNarration {
|
||||
source: NarrationSource;
|
||||
beats: BeatNarration[];
|
||||
}
|
||||
|
||||
// `satisfies` makes the compiler error if a new CinemaBeat['kind'] is added
|
||||
// without a chip here — closes the silent "undefined chip → blank UI" gap.
|
||||
const KIND_CHIP = {
|
||||
origin: 'Origin',
|
||||
connection: 'Connection',
|
||||
contradiction: 'Tension',
|
||||
recent: 'Now',
|
||||
bridge: 'Jump',
|
||||
surprise: 'Surprise',
|
||||
} satisfies Record<CinemaBeat['kind'], string>;
|
||||
|
||||
function snippet(content: string, max = 90): string {
|
||||
const s = (content ?? '').replace(/\s+/g, ' ').trim();
|
||||
if (s.length <= max) return s;
|
||||
return s.slice(0, max - 1).trimEnd() + '…';
|
||||
}
|
||||
|
||||
function typeLabel(nodeType: string): string {
|
||||
const t = (nodeType ?? 'memory').toLowerCase();
|
||||
return t.charAt(0).toUpperCase() + t.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier 2 — deterministic structured captions from real data only.
|
||||
* Never throws; always returns a caption per beat.
|
||||
*/
|
||||
export function localCaptions(path: CinemaPath): CinemaNarration {
|
||||
const beats: BeatNarration[] = path.beats.map((beat, i) => {
|
||||
const n = beat.node;
|
||||
const what = snippet(n.label || `(${typeLabel(n.type)} memory)`);
|
||||
let text: string;
|
||||
switch (beat.kind) {
|
||||
case 'origin':
|
||||
text = `We begin at a ${typeLabel(n.type).toLowerCase()} the graph is centered on — "${what}".`;
|
||||
break;
|
||||
case 'contradiction': {
|
||||
const via = beat.viaEdge?.type ? beat.viaEdge.type.replace(/_/g, ' ') : 'a conflict';
|
||||
text = `This is held in tension with the last memory through ${via}: "${what}".`;
|
||||
break;
|
||||
}
|
||||
case 'recent':
|
||||
text = `And where the mind is now — a recent memory: "${what}".`;
|
||||
break;
|
||||
case 'bridge':
|
||||
text = `Crossing to a separate cluster — "${what}".`;
|
||||
break;
|
||||
default: {
|
||||
const w = beat.viaEdge?.weight ?? 0;
|
||||
const strength = w > 0.66 ? 'strongly' : w > 0.33 ? 'closely' : 'loosely';
|
||||
text = `${strength} connected from there: a ${typeLabel(n.type).toLowerCase()} — "${what}".`;
|
||||
}
|
||||
}
|
||||
// Tags add texture when present.
|
||||
if (n.tags && n.tags.length > 0 && i > 0) {
|
||||
text += ` [${n.tags.slice(0, 3).join(', ')}]`;
|
||||
}
|
||||
return { nodeId: beat.nodeId, text, chip: KIND_CHIP[beat.kind] };
|
||||
});
|
||||
return { source: 'local-captions', beats };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the best available narration for a path.
|
||||
*
|
||||
* @param fetchBackend optional async fn that returns backend-LLM narration
|
||||
* beats (Tier 1). If it's absent, rejects, times out, or returns a mismatched
|
||||
* shape, we silently fall back to Tier 2 local captions. The caller passes
|
||||
* this only when the backend has advertised /api/narrative support.
|
||||
*/
|
||||
export async function resolveNarration(
|
||||
path: CinemaPath,
|
||||
fetchBackend?: () => Promise<BeatNarration[] | null>
|
||||
): Promise<CinemaNarration> {
|
||||
const fallback = localCaptions(path);
|
||||
if (!fetchBackend) return fallback;
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const backend = await Promise.race([
|
||||
fetchBackend(),
|
||||
new Promise<null>((resolve) => {
|
||||
timer = setTimeout(() => resolve(null), 6000);
|
||||
}),
|
||||
]);
|
||||
|
||||
// Keep only well-formed backend beats (guards against null/empty/garbage
|
||||
// entries that would otherwise produce blank captions mid-tour).
|
||||
const valid = Array.isArray(backend)
|
||||
? backend.filter(
|
||||
(b): b is BeatNarration =>
|
||||
!!b && typeof b.nodeId === 'string' && typeof b.text === 'string' && b.text.trim().length > 0
|
||||
)
|
||||
: [];
|
||||
if (valid.length === 0) return fallback;
|
||||
|
||||
// Align backend beats to the real path by nodeId; fill any gap from the
|
||||
// bounds-safe local caption so every beat always has text (never blank).
|
||||
const byNode = new Map(valid.map((b) => [b.nodeId, b]));
|
||||
const beats: BeatNarration[] = path.beats.map((beat, i) => {
|
||||
const hit = byNode.get(beat.nodeId);
|
||||
if (hit) {
|
||||
const chip = typeof hit.chip === 'string' && hit.chip.trim() ? hit.chip : KIND_CHIP[beat.kind];
|
||||
return { nodeId: beat.nodeId, text: hit.text, chip };
|
||||
}
|
||||
return (
|
||||
fallback.beats[i] ?? {
|
||||
nodeId: beat.nodeId,
|
||||
text: beat.node.label || '(unlabeled memory)',
|
||||
chip: KIND_CHIP[beat.kind],
|
||||
}
|
||||
);
|
||||
});
|
||||
return { source: 'backend-llm', beats };
|
||||
} catch {
|
||||
return fallback;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
171
apps/dashboard/src/lib/graph/cinema/pathfinder.ts
Normal file
171
apps/dashboard/src/lib/graph/cinema/pathfinder.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
// Memory Cinema — Tier 3: the bulletproof pathfinder.
|
||||
//
|
||||
// Plans a cinematic tour through the REAL memory graph using nothing but the
|
||||
// nodes + edges the backend already returns. This is the deterministic engine
|
||||
// that ALWAYS drives the camera, regardless of which narration tier (backend
|
||||
// LLM / local captions / none) is active. No WebGPU, no network, no LLM — if
|
||||
// everything else fails, this still produces a coherent, watchable flythrough.
|
||||
//
|
||||
// The path is intentionally a STORY, not a raw BFS dump:
|
||||
// 1. start at the center (the memory the graph is focused on)
|
||||
// 2. visit its strongest-weighted connections (what it's most tied to)
|
||||
// 3. detour to a contradiction edge if one exists (tension = interesting)
|
||||
// 4. end on a recently-created node (where the mind is now)
|
||||
// Falling back to plain weighted BFS when those signals are absent.
|
||||
|
||||
import type { GraphNode, GraphEdge } from '$types';
|
||||
|
||||
export interface CinemaBeat {
|
||||
/** Node this beat centers the camera on. */
|
||||
nodeId: string;
|
||||
/** The node payload, for the narrator + visuals. */
|
||||
node: GraphNode;
|
||||
/** Edge traversed to arrive here (null for the opening beat). */
|
||||
viaEdge: GraphEdge | null;
|
||||
/** Why this beat exists — drives the deterministic caption + visual emphasis. */
|
||||
kind: 'origin' | 'connection' | 'contradiction' | 'recent' | 'bridge' | 'surprise';
|
||||
/** 0..1 emphasis used by the sandbox to spike emissive/bloom on arrival. */
|
||||
intensity: number;
|
||||
}
|
||||
|
||||
export interface CinemaPath {
|
||||
beats: CinemaBeat[];
|
||||
/** The node the requested centerId resolved to (which the tour actually
|
||||
* starts from). May differ from the requested centerId when it was missing,
|
||||
* in which case `pivoted` is true — callers can surface this if they care. */
|
||||
centerId: string;
|
||||
/** True when the requested centerId did not exist and we picked a start node. */
|
||||
pivoted: boolean;
|
||||
/** Edges that should visibly "flow" during the tour, in beat order. */
|
||||
flowEdges: GraphEdge[];
|
||||
}
|
||||
|
||||
export interface Adjacency {
|
||||
[nodeId: string]: { edge: GraphEdge; otherId: string }[];
|
||||
}
|
||||
|
||||
export function buildAdjacency(edges: GraphEdge[]): Adjacency {
|
||||
const adj: Adjacency = {};
|
||||
for (const edge of edges) {
|
||||
(adj[edge.source] ??= []).push({ edge, otherId: edge.target });
|
||||
(adj[edge.target] ??= []).push({ edge, otherId: edge.source });
|
||||
}
|
||||
// Strongest connections first so the tour visits the most meaningful ties.
|
||||
for (const id of Object.keys(adj)) {
|
||||
adj[id].sort((a, b) => (b.edge.weight ?? 0) - (a.edge.weight ?? 0));
|
||||
}
|
||||
return adj;
|
||||
}
|
||||
|
||||
export function isContradictionEdge(edge: GraphEdge): boolean {
|
||||
const t = (edge.type ?? '').toLowerCase();
|
||||
return t.includes('contradict') || t.includes('conflict') || t.includes('supersede');
|
||||
}
|
||||
|
||||
export function recencyOf(node: GraphNode): number {
|
||||
// Larger = more recent. Tolerates missing/invalid timestamps.
|
||||
const t = Date.parse(node.updatedAt || node.createdAt || '');
|
||||
return Number.isFinite(t) ? t : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan a cinematic path over the real graph.
|
||||
*
|
||||
* @param maxBeats hard cap on tour length (keeps the flythrough watchable).
|
||||
* Deterministic: same inputs always yield the same path (no randomness), so the
|
||||
* recorded launch GIF is reproducible.
|
||||
*/
|
||||
export function planCinemaPath(
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
centerId: string,
|
||||
maxBeats = 7
|
||||
): CinemaPath {
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]));
|
||||
const empty: CinemaPath = { beats: [], centerId, pivoted: false, flowEdges: [] };
|
||||
if (nodes.length === 0) return empty;
|
||||
|
||||
// Resolve a real starting node: prefer centerId, else the explicit center
|
||||
// flag, else the most-connected node, else the first node. Track whether we
|
||||
// had to pivot off the requested centerId so callers can surface it.
|
||||
const adj = buildAdjacency(edges);
|
||||
const requestedExists = byId.has(centerId);
|
||||
let startId = requestedExists ? centerId : '';
|
||||
if (!startId) startId = nodes.find((n) => n.isCenter)?.id ?? '';
|
||||
if (!startId) {
|
||||
startId = nodes
|
||||
.map((n) => ({ id: n.id, deg: adj[n.id]?.length ?? 0 }))
|
||||
.sort((a, b) => b.deg - a.deg)[0].id;
|
||||
}
|
||||
const start = byId.get(startId);
|
||||
if (!start) return empty;
|
||||
const pivoted = !requestedExists;
|
||||
|
||||
const visited = new Set<string>([startId]);
|
||||
const beats: CinemaBeat[] = [
|
||||
{ nodeId: startId, node: start, viaEdge: null, kind: 'origin', intensity: 1 },
|
||||
];
|
||||
const flowEdges: GraphEdge[] = [];
|
||||
|
||||
// Greedy weighted walk: from the current frontier, step to the strongest
|
||||
// unvisited neighbour, with a one-time detour to a contradiction if reachable.
|
||||
let current = startId;
|
||||
let contradictionUsed = false;
|
||||
|
||||
while (beats.length < maxBeats) {
|
||||
const neighbours = adj[current] ?? [];
|
||||
|
||||
// Prefer an unused contradiction edge once — tension makes a better story.
|
||||
let next: { edge: GraphEdge; otherId: string } | undefined;
|
||||
if (!contradictionUsed) {
|
||||
next = neighbours.find((n) => !visited.has(n.otherId) && isContradictionEdge(n.edge));
|
||||
if (next) contradictionUsed = true;
|
||||
}
|
||||
// Otherwise the strongest unvisited tie.
|
||||
if (!next) next = neighbours.find((n) => !visited.has(n.otherId));
|
||||
|
||||
// Dead end: hop to the most recent unvisited node anywhere (a "bridge"
|
||||
// cut) so the tour can keep going instead of stalling.
|
||||
if (!next) {
|
||||
const remaining = nodes
|
||||
.filter((n) => !visited.has(n.id))
|
||||
.sort((a, b) => recencyOf(b) - recencyOf(a));
|
||||
if (remaining.length === 0) break;
|
||||
const node = remaining[0];
|
||||
visited.add(node.id);
|
||||
beats.push({ nodeId: node.id, node, viaEdge: null, kind: 'bridge', intensity: 0.6 });
|
||||
current = node.id;
|
||||
continue;
|
||||
}
|
||||
|
||||
const node = byId.get(next.otherId);
|
||||
if (!node) {
|
||||
visited.add(next.otherId);
|
||||
continue;
|
||||
}
|
||||
visited.add(node.id);
|
||||
flowEdges.push(next.edge);
|
||||
beats.push({
|
||||
nodeId: node.id,
|
||||
node,
|
||||
viaEdge: next.edge,
|
||||
kind: isContradictionEdge(next.edge) ? 'contradiction' : 'connection',
|
||||
intensity: isContradictionEdge(next.edge) ? 1 : Math.min(1, 0.55 + (next.edge.weight ?? 0) * 0.45),
|
||||
});
|
||||
current = node.id;
|
||||
}
|
||||
|
||||
// Closing beat: end on the single most-recent node not already the finale,
|
||||
// so the tour lands on "where the memory is now". Only if it adds variety.
|
||||
if (beats.length < maxBeats) {
|
||||
const last = beats[beats.length - 1].nodeId;
|
||||
const recent = nodes
|
||||
.filter((n) => n.id !== last)
|
||||
.sort((a, b) => recencyOf(b) - recencyOf(a))[0];
|
||||
if (recent && !beats.some((b) => b.nodeId === recent.id)) {
|
||||
beats.push({ nodeId: recent.id, node: recent, viaEdge: null, kind: 'recent', intensity: 0.8 });
|
||||
}
|
||||
}
|
||||
|
||||
return { beats, centerId: startId, pivoted, flowEdges };
|
||||
}
|
||||
283
apps/dashboard/src/lib/graph/cinema/sandbox.ts
Normal file
283
apps/dashboard/src/lib/graph/cinema/sandbox.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
// Memory Cinema — the isolated WebGPU sandbox.
|
||||
//
|
||||
// Boots a SEPARATE WebGPU canvas + scene on Cinema launch. The legacy WebGL
|
||||
// graph (nebula, grain, every current user's experience) is never touched —
|
||||
// zero regression by construction. Inside the sandbox: the SemanticComputeStorm
|
||||
// + selective MRT emissive bloom, driven by the CinemaDirector's beats.
|
||||
//
|
||||
// Everything here is dynamically imported (three/webgpu, three/tsl, storm.ts)
|
||||
// so the heavy WebGPU bundle stays out of the main app. If WebGPU is
|
||||
// unavailable, isSupported() returns false and the caller falls back to the
|
||||
// camera-only flythrough on the existing canvas (captions still play).
|
||||
|
||||
import * as THREE from 'three';
|
||||
import type { SemanticRole, SemanticComputeStorm } from './storm';
|
||||
|
||||
// The storm lives at the world origin, permanently. The camera always looks here
|
||||
// and is clamped to a safe distance band so the subject can never leave frame.
|
||||
const ORIGIN = new THREE.Vector3(0, 0, 0);
|
||||
// Keep the camera in a narrow, fairly FAR band so the contained storm always
|
||||
// sits comfortably small and centered in frame (a closer camera makes the cloud
|
||||
// fill — and spill past — the edges once the bloom halo is added).
|
||||
const MIN_CAM_DIST = 30;
|
||||
const MAX_CAM_DIST = 44;
|
||||
|
||||
export function isWebGPUSupported(): boolean {
|
||||
return typeof navigator !== 'undefined' && 'gpu' in navigator;
|
||||
}
|
||||
|
||||
interface SandboxDeps {
|
||||
WebGPURenderer: new (params: object) => {
|
||||
init: () => Promise<void>;
|
||||
setSize: (w: number, h: number) => void;
|
||||
setPixelRatio: (r: number) => void;
|
||||
renderAsync: (scene: THREE.Scene, camera: THREE.Camera) => Promise<void>;
|
||||
computeAsync: (node: unknown) => Promise<void>;
|
||||
domElement: HTMLCanvasElement;
|
||||
dispose?: () => void;
|
||||
};
|
||||
PostProcessing: new (renderer: unknown) => { renderAsync: () => Promise<void>; outputNode: unknown };
|
||||
StormCtor: typeof SemanticComputeStorm;
|
||||
tsl: typeof import('three/tsl');
|
||||
bloomMod: { bloom: (node: unknown, strength?: number, radius?: number, threshold?: number) => unknown };
|
||||
}
|
||||
|
||||
export class CinemaSandbox {
|
||||
private container: HTMLElement;
|
||||
private deps!: SandboxDeps;
|
||||
private renderer!: SandboxDeps['WebGPURenderer']['prototype'];
|
||||
// Scene/camera are created in boot() from the three/webgpu module so every
|
||||
// object handed to the WebGPU renderer comes from the SAME Three.js instance
|
||||
// (avoids the "multiple instances of Three.js" incompatibility — the base
|
||||
// three import is used only for the shared Vector3 math type the director
|
||||
// mutates, which is identical across instances).
|
||||
private scene!: THREE.Scene;
|
||||
private camera!: THREE.PerspectiveCamera;
|
||||
private storm!: SemanticComputeStorm;
|
||||
private post: { renderAsync: () => Promise<void> } | null = null;
|
||||
private booted = false;
|
||||
|
||||
/** Camera target the director drives; mirrored into camera.lookAt each frame. */
|
||||
readonly target = new THREE.Vector3(0, 0, 0);
|
||||
|
||||
// FLYTHROUGH — when >0, relaxes the camera-distance clamp floor so the camera
|
||||
// can plunge inside the shell, and the storm stretches sprites along the
|
||||
// apparent motion vector. Camera velocity is derived per-frame from the
|
||||
// position delta (one Vector3, no compute). 0 = no streak (reduced-motion).
|
||||
private flythrough = 0;
|
||||
private prevCamPos = new THREE.Vector3();
|
||||
private camVel = new THREE.Vector3();
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.container = container;
|
||||
}
|
||||
|
||||
get cameraRef(): THREE.PerspectiveCamera {
|
||||
return this.camera;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the WebGPU pipeline. Throws if WebGPU is unsupported or init fails —
|
||||
* the caller treats a throw as "fall back to camera-only mode".
|
||||
*/
|
||||
async boot(): Promise<void> {
|
||||
if (this.booted) return;
|
||||
if (!isWebGPUSupported()) throw new Error('WebGPU not supported');
|
||||
|
||||
// Dynamic imports keep three/webgpu out of the main bundle.
|
||||
const webgpu = (await import('three/webgpu')) as unknown as {
|
||||
WebGPURenderer: SandboxDeps['WebGPURenderer'];
|
||||
PostProcessing: SandboxDeps['PostProcessing'];
|
||||
Scene: new () => THREE.Scene;
|
||||
PerspectiveCamera: new (fov: number, aspect: number, near: number, far: number) => THREE.PerspectiveCamera;
|
||||
Color: new (hex: number) => THREE.Color;
|
||||
};
|
||||
const tsl = (await import('three/tsl')) as typeof import('three/tsl');
|
||||
// bloom() lives in the TSL display helpers; import the node module.
|
||||
const bloomMod = (await import(
|
||||
'three/examples/jsm/tsl/display/BloomNode.js'
|
||||
)) as unknown as SandboxDeps['bloomMod'];
|
||||
const { SemanticComputeStorm } = await import('./storm');
|
||||
|
||||
this.deps = {
|
||||
WebGPURenderer: webgpu.WebGPURenderer,
|
||||
PostProcessing: webgpu.PostProcessing,
|
||||
StormCtor: SemanticComputeStorm,
|
||||
tsl,
|
||||
bloomMod,
|
||||
};
|
||||
|
||||
// Fail loud if the dynamic import didn't yield the expected constructors,
|
||||
// instead of a cryptic "undefined is not a constructor" later.
|
||||
if (!webgpu.WebGPURenderer || !webgpu.Scene || !webgpu.PerspectiveCamera || !webgpu.Color) {
|
||||
throw new Error('[cinema] three/webgpu is missing expected exports');
|
||||
}
|
||||
|
||||
// Build scene + camera from the SAME (webgpu) module instance the
|
||||
// renderer + storm use, so all objects are instance-compatible.
|
||||
const w = Math.max(1, this.container.clientWidth);
|
||||
const h = Math.max(1, this.container.clientHeight);
|
||||
this.scene = new webgpu.Scene();
|
||||
this.scene.background = new webgpu.Color(0x02020a);
|
||||
this.camera = new webgpu.PerspectiveCamera(60, w / h, 0.1, 2000);
|
||||
this.camera.position.set(0, 18, 60);
|
||||
|
||||
const renderer = new this.deps.WebGPURenderer({ antialias: true, alpha: false });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setSize(w, h);
|
||||
// CRITICAL FOOTGUN: WebGPU init is async. Must await before first render
|
||||
// or the canvas silently draws nothing.
|
||||
await renderer.init();
|
||||
this.container.appendChild(renderer.domElement);
|
||||
this.renderer = renderer;
|
||||
|
||||
// The compute storm (150k GPU particles).
|
||||
this.storm = new this.deps.StormCtor(renderer, this.scene, {});
|
||||
|
||||
// Selective MRT bloom: scene pass emits an emissive MRT; bloom only the
|
||||
// emissive channel so the storm blazes against the void without washing
|
||||
// the whole frame to grey. Falls back to a plain pass if MRT setup
|
||||
// throws on a given driver.
|
||||
try {
|
||||
const { pass, mrt, output, emissive } = this.deps.tsl as unknown as {
|
||||
pass: (s: THREE.Scene, c: THREE.Camera) => {
|
||||
setMRT: (m: unknown) => void;
|
||||
getTextureNode: (name: string) => unknown;
|
||||
};
|
||||
mrt: (cfg: Record<string, unknown>) => unknown;
|
||||
output: unknown;
|
||||
emissive: unknown;
|
||||
};
|
||||
const scenePass = pass(this.scene, this.camera);
|
||||
if (typeof scenePass?.setMRT !== 'function' || typeof scenePass?.getTextureNode !== 'function') {
|
||||
throw new Error('three/tsl pass() API mismatch — setMRT/getTextureNode missing');
|
||||
}
|
||||
scenePass.setMRT(mrt({ output, emissive }));
|
||||
const outputTex = scenePass.getTextureNode('output');
|
||||
const emissiveTex = scenePass.getTextureNode('emissive');
|
||||
// Gentler bloom (strength 0.6, threshold 0.35) so it accents the bright
|
||||
// cores instead of washing the whole colored cloud to white.
|
||||
const bloomed = this.deps.bloomMod.bloom(emissiveTex, 0.6, 0.65, 0.35);
|
||||
const post = new this.deps.PostProcessing(renderer);
|
||||
(post as unknown as { outputNode: unknown }).outputNode = (
|
||||
outputTex as { add: (n: unknown) => unknown }
|
||||
).add(bloomed);
|
||||
this.post = post as unknown as { renderAsync: () => Promise<void> };
|
||||
} catch (e) {
|
||||
// MRT/bloom unavailable on this driver — render straight, no crash.
|
||||
console.warn('[cinema] selective bloom unavailable, rendering without MRT:', e);
|
||||
this.post = null;
|
||||
}
|
||||
|
||||
this.booted = true;
|
||||
}
|
||||
|
||||
/** Retarget the storm's MODE/ignition for a beat. The storm is permanently
|
||||
* centered at the WORLD ORIGIN (see render) so it is always dead-center in
|
||||
* frame — worldPos here only conveys which node, not where the storm sits.
|
||||
* `act` lets the storm hold Act I dimmer (it opens too hot otherwise). */
|
||||
transitionTo(
|
||||
role: SemanticRole,
|
||||
_worldPos: THREE.Vector3,
|
||||
act: 'I' | 'II' | 'III' = 'II',
|
||||
beatIndex = 99
|
||||
): void {
|
||||
if (!this.booted) return;
|
||||
this.storm.transitionTo(role, ORIGIN, act, beatIndex);
|
||||
}
|
||||
|
||||
/** Fire one endless-dream beat — a random crazier figure + color blast. Called
|
||||
* on a timer after the scripted tour ends so the storm never sits idle. */
|
||||
dreamBeat(): void {
|
||||
if (!this.booted) return;
|
||||
this.storm.dreamBeat();
|
||||
}
|
||||
|
||||
/** Flythrough strength 0..1. Relaxes the clamp floor so the camera can dive
|
||||
* inside the shell, and drives the storm's velocity-stretch streak. Set to 0
|
||||
* for reduced-motion (no streak, normal clamp). */
|
||||
setFlythrough(s: number): void {
|
||||
this.flythrough = THREE.MathUtils.clamp(s, 0, 1);
|
||||
if (this.booted) this.storm.setStreak(s);
|
||||
}
|
||||
|
||||
/** Pass-through: set the storm streak strength directly. */
|
||||
setStreak(s: number): void {
|
||||
if (this.booted) this.storm.setStreak(s);
|
||||
}
|
||||
|
||||
/** Pass-through: push view-space camera velocity to the storm. */
|
||||
setCameraVel(v: THREE.Vector3): void {
|
||||
if (this.booted) this.storm.setCameraVel(v);
|
||||
}
|
||||
|
||||
/** Render one frame. The storm is pinned to the origin and the camera always
|
||||
* looks at the origin, so the storm CANNOT leave the frame. The director
|
||||
* varies only the camera's orbital position/angle (set via cameraRef), and we
|
||||
* clamp that to a safe distance band here as a final guarantee. */
|
||||
async render(deltaSeconds: number): Promise<void> {
|
||||
if (!this.booted) return;
|
||||
|
||||
// Camera velocity (world units / sec) from the position delta this frame —
|
||||
// one Vector3 subtract, no compute. Captured BEFORE the clamp so it tracks
|
||||
// the director's intended move (the clamp only rescues runaway distances).
|
||||
this.camVel.copy(this.camera.position).sub(this.prevCamPos).divideScalar(Math.max(deltaSeconds, 1e-3));
|
||||
this.prevCamPos.copy(this.camera.position);
|
||||
|
||||
// Hard guarantee: clamp the camera into a distance band from origin so a
|
||||
// runaway director move can never push the subject out of view, then look
|
||||
// dead at the origin where the storm lives. Flythrough relaxes the floor
|
||||
// toward 6 so the camera can plunge inside the shell; the MAX clamp stands.
|
||||
const minDist = THREE.MathUtils.lerp(MIN_CAM_DIST, 6, this.flythrough);
|
||||
const distToOrigin = this.camera.position.length();
|
||||
if (distToOrigin < minDist || distToOrigin > MAX_CAM_DIST || !Number.isFinite(distToOrigin)) {
|
||||
const d = Math.min(MAX_CAM_DIST, Math.max(minDist, distToOrigin || MAX_CAM_DIST));
|
||||
if (distToOrigin > 1e-3) this.camera.position.setLength(d);
|
||||
else this.camera.position.set(0, 12, d);
|
||||
}
|
||||
this.camera.lookAt(ORIGIN);
|
||||
|
||||
// Push view-space apparent particle velocity to the storm (negated world
|
||||
// camera velocity transformed into view space → the direction sprites
|
||||
// appear to streak). matrixWorldInverse is the previous frame's (the
|
||||
// renderer refreshes it during renderAsync below) — a one-frame lag that is
|
||||
// imperceptible for a streak direction.
|
||||
const camVelView = this.camVel
|
||||
.clone()
|
||||
.applyMatrix3(new THREE.Matrix3().setFromMatrix4(this.camera.matrixWorldInverse))
|
||||
.negate();
|
||||
this.storm.setCameraVel(camVelView);
|
||||
|
||||
// Size the containment sphere to the camera's VERTICAL FOV at the origin
|
||||
// (the limiting dimension on a landscape frame). 0.82 lets the storm fill
|
||||
// most of the frame; the storm's internal shell sits well inside this and
|
||||
// the hard boundary snap keeps the bloom halo from spilling past the edge.
|
||||
const dist = this.camera.position.length();
|
||||
const vfov = (this.camera.fov * Math.PI) / 180;
|
||||
const fitRadius = Math.tan(vfov / 2) * dist * 0.82;
|
||||
this.storm.setContainRadius(fitRadius);
|
||||
|
||||
await this.storm.update(deltaSeconds);
|
||||
if (this.post) await this.post.renderAsync();
|
||||
else await this.renderer.renderAsync(this.scene, this.camera);
|
||||
}
|
||||
|
||||
resize(): void {
|
||||
if (!this.booted) return;
|
||||
const w = Math.max(1, this.container.clientWidth);
|
||||
const h = Math.max(1, this.container.clientHeight);
|
||||
this.camera.aspect = w / h;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.renderer.setSize(w, h);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (!this.booted) return;
|
||||
this.storm?.dispose();
|
||||
this.renderer?.dispose?.();
|
||||
if (this.renderer?.domElement?.parentNode) {
|
||||
this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
|
||||
}
|
||||
this.booted = false;
|
||||
}
|
||||
}
|
||||
1006
apps/dashboard/src/lib/graph/cinema/storm.ts
Normal file
1006
apps/dashboard/src/lib/graph/cinema/storm.ts
Normal file
File diff suppressed because it is too large
Load diff
225
apps/dashboard/src/lib/graph/cinema/topology.ts
Normal file
225
apps/dashboard/src/lib/graph/cinema/topology.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
// The Auteur — graph signal extraction.
|
||||
//
|
||||
// Pure, dependency-free statistics over the REAL /api/graph data, computed once
|
||||
// per Cinema launch. These signals are what gives the AI director something
|
||||
// meaningful to direct: which memory is most load-bearing (betweenness), where
|
||||
// tension lives (contradictions), what's surprising (distant-but-plausible
|
||||
// links), what's fading (low retention / suppression). No LLM, no WebGPU, no
|
||||
// network — fully headless-testable.
|
||||
|
||||
import type { GraphNode, GraphEdge } from '$types';
|
||||
import { buildAdjacency, recencyOf, isContradictionEdge } from './pathfinder';
|
||||
|
||||
export interface NodeSignal {
|
||||
nodeId: string;
|
||||
/** Raw connection count. */
|
||||
degree: number;
|
||||
/** Brandes betweenness centrality, normalized 0..1 — how load-bearing this
|
||||
* memory is as a bridge between clusters. The director favors high-betweenness
|
||||
* nodes for hero shots. */
|
||||
betweenness: number;
|
||||
/** Connected-component id (which cluster of memory this belongs to). */
|
||||
clusterId: number;
|
||||
/** 0..1, 1 = most recent. */
|
||||
recencyRank: number;
|
||||
/** FSRS retention 0..1. */
|
||||
retention: number;
|
||||
/** Suppression pressure 0..1 (memory actively being forgotten). */
|
||||
suppression: number;
|
||||
}
|
||||
|
||||
export interface EdgeSignal {
|
||||
source: string;
|
||||
target: string;
|
||||
isContradiction: boolean;
|
||||
isMergeSupersede: boolean;
|
||||
/** 0..1: high when endpoints share neighbors (plausible) yet the edge weight
|
||||
* is low (distant) — a surprising, non-obvious connection. */
|
||||
surprise: number;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface GraphSignals {
|
||||
nodes: Map<string, NodeSignal>;
|
||||
edges: EdgeSignal[];
|
||||
clusterCount: number;
|
||||
/** Node id with the single highest betweenness — the graph's keystone. */
|
||||
peakBetweennessId: string;
|
||||
}
|
||||
|
||||
function isMergeSupersedeEdge(edge: GraphEdge): boolean {
|
||||
const t = (edge.type ?? '').toLowerCase();
|
||||
return t.includes('merge') || t.includes('supersede') || t.includes('duplicate');
|
||||
}
|
||||
|
||||
/**
|
||||
* Brandes' algorithm for betweenness centrality on an unweighted, undirected
|
||||
* graph. O(V·E) — fine for /api/graph payloads. Returns raw (unnormalized)
|
||||
* scores keyed by node id; the caller normalizes.
|
||||
*/
|
||||
function brandesBetweenness(nodeIds: string[], adj: Record<string, { otherId: string }[]>): Map<string, number> {
|
||||
const cb = new Map<string, number>();
|
||||
for (const v of nodeIds) cb.set(v, 0);
|
||||
|
||||
for (const s of nodeIds) {
|
||||
const stack: string[] = [];
|
||||
const pred = new Map<string, string[]>();
|
||||
const sigma = new Map<string, number>();
|
||||
const dist = new Map<string, number>();
|
||||
for (const v of nodeIds) {
|
||||
pred.set(v, []);
|
||||
sigma.set(v, 0);
|
||||
dist.set(v, -1);
|
||||
}
|
||||
sigma.set(s, 1);
|
||||
dist.set(s, 0);
|
||||
|
||||
// BFS (unweighted shortest paths).
|
||||
const queue: string[] = [s];
|
||||
let head = 0;
|
||||
while (head < queue.length) {
|
||||
const v = queue[head++];
|
||||
stack.push(v);
|
||||
for (const { otherId: w } of adj[v] ?? []) {
|
||||
if ((dist.get(w) ?? -1) < 0) {
|
||||
dist.set(w, (dist.get(v) ?? 0) + 1);
|
||||
queue.push(w);
|
||||
}
|
||||
if ((dist.get(w) ?? -1) === (dist.get(v) ?? 0) + 1) {
|
||||
sigma.set(w, (sigma.get(w) ?? 0) + (sigma.get(v) ?? 0));
|
||||
pred.get(w)!.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Accumulation (back-propagate dependencies).
|
||||
const delta = new Map<string, number>();
|
||||
for (const v of nodeIds) delta.set(v, 0);
|
||||
while (stack.length > 0) {
|
||||
const w = stack.pop()!;
|
||||
for (const v of pred.get(w) ?? []) {
|
||||
const c = ((sigma.get(v) ?? 0) / (sigma.get(w) || 1)) * (1 + (delta.get(w) ?? 0));
|
||||
delta.set(v, (delta.get(v) ?? 0) + c);
|
||||
}
|
||||
if (w !== s) cb.set(w, (cb.get(w) ?? 0) + (delta.get(w) ?? 0));
|
||||
}
|
||||
}
|
||||
return cb;
|
||||
}
|
||||
|
||||
/** Union-find connected components → a cluster id per node. */
|
||||
function components(nodeIds: string[], edges: GraphEdge[]): { clusterOf: Map<string, number>; count: number } {
|
||||
const parent = new Map<string, string>();
|
||||
for (const id of nodeIds) parent.set(id, id);
|
||||
const find = (x: string): string => {
|
||||
let root = x;
|
||||
while (parent.get(root) !== root) root = parent.get(root)!;
|
||||
// Path compression.
|
||||
let cur = x;
|
||||
while (parent.get(cur) !== root) {
|
||||
const next = parent.get(cur)!;
|
||||
parent.set(cur, root);
|
||||
cur = next;
|
||||
}
|
||||
return root;
|
||||
};
|
||||
const union = (a: string, b: string) => {
|
||||
const ra = find(a);
|
||||
const rb = find(b);
|
||||
if (ra !== rb) parent.set(ra, rb);
|
||||
};
|
||||
for (const e of edges) {
|
||||
if (parent.has(e.source) && parent.has(e.target)) union(e.source, e.target);
|
||||
}
|
||||
const rootToCluster = new Map<string, number>();
|
||||
const clusterOf = new Map<string, number>();
|
||||
let next = 0;
|
||||
for (const id of nodeIds) {
|
||||
const r = find(id);
|
||||
if (!rootToCluster.has(r)) rootToCluster.set(r, next++);
|
||||
clusterOf.set(id, rootToCluster.get(r)!);
|
||||
}
|
||||
return { clusterOf, count: next };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute all director signals from the real graph. Pure; safe to call once at
|
||||
* launch. Caps betweenness work on very large graphs by limiting to the
|
||||
* top-degree subset (the only nodes that can carry meaningful centrality).
|
||||
*/
|
||||
export function computeSignals(nodes: GraphNode[], edges: GraphEdge[]): GraphSignals {
|
||||
const nodeIds = nodes.map((n) => n.id);
|
||||
const adj = buildAdjacency(edges);
|
||||
|
||||
// Recency ranking (0..1, 1 = newest).
|
||||
const byRecency = [...nodes].sort((a, b) => recencyOf(a) - recencyOf(b));
|
||||
const recencyRank = new Map<string, number>();
|
||||
byRecency.forEach((n, i) => recencyRank.set(n.id, nodes.length > 1 ? i / (nodes.length - 1) : 1));
|
||||
|
||||
// Betweenness — guard pathological sizes: above the cap, compute on the
|
||||
// top-degree subset (others get 0; they can't be meaningful bridges anyway).
|
||||
const BETWEENNESS_CAP = 600;
|
||||
let betweennessNodes = nodeIds;
|
||||
if (nodeIds.length > BETWEENNESS_CAP) {
|
||||
betweennessNodes = [...nodeIds]
|
||||
.sort((a, b) => (adj[b]?.length ?? 0) - (adj[a]?.length ?? 0))
|
||||
.slice(0, BETWEENNESS_CAP);
|
||||
}
|
||||
const rawBetween = brandesBetweenness(betweennessNodes, adj);
|
||||
let maxBetween = 0;
|
||||
for (const v of rawBetween.values()) maxBetween = Math.max(maxBetween, v);
|
||||
|
||||
const { clusterOf, count: clusterCount } = components(nodeIds, edges);
|
||||
|
||||
const maxSuppression = Math.max(1, ...nodes.map((n) => n.suppression_count ?? 0));
|
||||
|
||||
const nodeSignals = new Map<string, NodeSignal>();
|
||||
let peakBetweennessId = nodeIds[0] ?? '';
|
||||
let peakVal = -1;
|
||||
for (const n of nodes) {
|
||||
const bt = maxBetween > 0 ? (rawBetween.get(n.id) ?? 0) / maxBetween : 0;
|
||||
if (bt > peakVal) {
|
||||
peakVal = bt;
|
||||
peakBetweennessId = n.id;
|
||||
}
|
||||
nodeSignals.set(n.id, {
|
||||
nodeId: n.id,
|
||||
degree: adj[n.id]?.length ?? 0,
|
||||
betweenness: bt,
|
||||
clusterId: clusterOf.get(n.id) ?? 0,
|
||||
recencyRank: recencyRank.get(n.id) ?? 0,
|
||||
retention: clamp01(n.retention ?? 0),
|
||||
suppression: clamp01((n.suppression_count ?? 0) / maxSuppression),
|
||||
});
|
||||
}
|
||||
|
||||
// Edge signals incl. surprise (shared-neighbor overlap × edge distance).
|
||||
const neighborSets = new Map<string, Set<string>>();
|
||||
for (const id of nodeIds) neighborSets.set(id, new Set((adj[id] ?? []).map((a) => a.otherId)));
|
||||
const edgeSignals: EdgeSignal[] = edges.map((e) => {
|
||||
const a = neighborSets.get(e.source);
|
||||
const b = neighborSets.get(e.target);
|
||||
let shared = 0;
|
||||
if (a && b) {
|
||||
const [small, large] = a.size < b.size ? [a, b] : [b, a];
|
||||
for (const x of small) if (large.has(x)) shared++;
|
||||
}
|
||||
const union = (a?.size ?? 0) + (b?.size ?? 0) - shared || 1;
|
||||
const overlap = shared / union; // Jaccard: structural plausibility.
|
||||
const distance = 1 - clamp01(e.weight ?? 0); // low weight = semantically distant.
|
||||
return {
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
isContradiction: isContradictionEdge(e),
|
||||
isMergeSupersede: isMergeSupersedeEdge(e),
|
||||
surprise: clamp01(overlap * distance * 2), // plausible AND distant = surprising.
|
||||
weight: e.weight ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
return { nodes: nodeSignals, edges: edgeSignals, clusterCount, peakBetweennessId };
|
||||
}
|
||||
|
||||
function clamp01(x: number): number {
|
||||
return Math.max(0, Math.min(1, Number.isFinite(x) ? x : 0));
|
||||
}
|
||||
|
|
@ -6,11 +6,13 @@ const MAX_EVENTS = 200;
|
|||
function createWebSocketStore() {
|
||||
const { subscribe, set, update } = writable<{
|
||||
connected: boolean;
|
||||
reconnecting: boolean;
|
||||
events: VestigeEvent[];
|
||||
lastHeartbeat: VestigeEvent | null;
|
||||
error: string | null;
|
||||
}>({
|
||||
connected: false,
|
||||
reconnecting: false,
|
||||
events: [],
|
||||
lastHeartbeat: null,
|
||||
error: null
|
||||
|
|
@ -32,7 +34,7 @@ function createWebSocketStore() {
|
|||
|
||||
ws.onopen = () => {
|
||||
reconnectAttempts = 0;
|
||||
update(s => ({ ...s, connected: true, error: null }));
|
||||
update(s => ({ ...s, connected: true, reconnecting: false, error: null }));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
|
|
@ -65,6 +67,7 @@ function createWebSocketStore() {
|
|||
|
||||
function scheduleReconnect(url: string) {
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
update(s => ({ ...s, reconnecting: true }));
|
||||
const delay = Math.min(1000 * 2 ** reconnectAttempts, 30000);
|
||||
reconnectAttempts++;
|
||||
reconnectTimer = setTimeout(() => connect(url), delay);
|
||||
|
|
@ -74,7 +77,7 @@ function createWebSocketStore() {
|
|||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
ws?.close();
|
||||
ws = null;
|
||||
set({ connected: false, events: [], lastHeartbeat: null, error: null });
|
||||
set({ connected: false, reconnecting: false, events: [], lastHeartbeat: null, error: null });
|
||||
}
|
||||
|
||||
function clearEvents() {
|
||||
|
|
@ -108,6 +111,7 @@ export const websocket = createWebSocketStore();
|
|||
|
||||
// Derived stores for specific event types
|
||||
export const isConnected = derived(websocket, $ws => $ws.connected);
|
||||
export const isReconnecting = derived(websocket, $ws => $ws.reconnecting);
|
||||
export const eventFeed = derived(websocket, $ws => $ws.events);
|
||||
export const heartbeat = derived(websocket, $ws => $ws.lastHeartbeat);
|
||||
export const memoryCount = derived(websocket, $ws =>
|
||||
|
|
|
|||
|
|
@ -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 & 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
|
||||
|
|
|
|||
|
|
@ -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]'}"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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'}">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,14 @@
|
|||
import RetentionCurve from '$components/RetentionCurve.svelte';
|
||||
import TimeSlider from '$components/TimeSlider.svelte';
|
||||
import MemoryStateLegend from '$components/MemoryStateLegend.svelte';
|
||||
// ⚠ MEMORY CINEMA — DO NOT MODIFY. This is the flawless, protected
|
||||
// AI-narrated flythrough experience. The graph-control overhaul below
|
||||
// (icons, dropdown, alive empty states) is a SEPARATE concern and must
|
||||
// never touch the Cinema import, element, or its props.
|
||||
import MemoryCinema from '$components/MemoryCinema.svelte';
|
||||
// Graph-control overhaul (2026 alive UI): unique icons + dropdown.
|
||||
import Icon from '$components/Icon.svelte';
|
||||
import Dropdown, { type DropdownOption } from '$components/Dropdown.svelte';
|
||||
import { api } from '$stores/api';
|
||||
import { eventFeed } from '$stores/websocket';
|
||||
import { graphState } from '$stores/graph-state.svelte';
|
||||
|
|
@ -20,6 +28,19 @@
|
|||
let isDreaming = $state(false);
|
||||
let searchQuery = $state('');
|
||||
let maxNodes = $state(150);
|
||||
// Node-count choices for the clear Dropdown control (replaces a native
|
||||
// <select>). Bound as a string; coerced back to a number on change.
|
||||
const nodeCountOptions: DropdownOption[] = [
|
||||
{ value: '50', label: '50 nodes' },
|
||||
{ value: '100', label: '100 nodes' },
|
||||
{ value: '150', label: '150 nodes' },
|
||||
{ value: '200', label: '200 nodes' },
|
||||
];
|
||||
let maxNodesChoice = $state('150');
|
||||
function onMaxNodesChange(v: string) {
|
||||
maxNodes = parseInt(v, 10);
|
||||
loadGraph();
|
||||
}
|
||||
let temporalEnabled = $state(false);
|
||||
let temporalDate = $state(new Date());
|
||||
// Colour spheres by node type, FSRS memory state, or AhaGraph learning tags.
|
||||
|
|
@ -203,14 +224,14 @@
|
|||
<div class="h-full flex items-center justify-center">
|
||||
<div class="text-center space-y-4">
|
||||
<div class="w-16 h-16 mx-auto rounded-full border-2 border-synapse/30 border-t-synapse animate-spin"></div>
|
||||
<p class="text-dim text-sm">Loading memory graph...</p>
|
||||
<p class="text-dim text-sm">Weaving your memory graph…</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if error === 'OFFLINE'}
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div class="text-center space-y-5 max-w-lg px-8">
|
||||
<div class="text-5xl opacity-40">⚡</div>
|
||||
<h2 class="text-xl text-bright">MCP Backend Offline</h2>
|
||||
<div class="text-center space-y-5 max-w-lg px-8 enter">
|
||||
<div class="mx-auto w-fit text-warning opacity-70 breathe"><Icon name="activation" size={52} strokeWidth={1.2} /></div>
|
||||
<h2 class="text-xl text-bright text-aurora">MCP Backend Offline</h2>
|
||||
<p class="text-dim text-sm leading-relaxed">
|
||||
The Vestige MCP server isn't reachable on <code class="font-mono text-muted">:3927</code>.
|
||||
The dashboard is running but has nothing to query.
|
||||
|
|
@ -234,17 +255,17 @@ disown</code>
|
|||
</div>
|
||||
{:else if error === 'EMPTY'}
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div class="text-center space-y-4 max-w-md px-8">
|
||||
<div class="text-5xl opacity-30">◎</div>
|
||||
<h2 class="text-xl text-bright">Your Mind Awaits</h2>
|
||||
<p class="text-dim text-sm">No memories yet. Start using Vestige to populate your graph.</p>
|
||||
<div class="text-center space-y-4 max-w-md px-8 enter">
|
||||
<div class="mx-auto w-fit text-synapse-glow opacity-50 breathe"><Icon name="graph" size={52} strokeWidth={1.2} /></div>
|
||||
<h2 class="text-xl text-bright text-aurora">Your Mind Awaits</h2>
|
||||
<p class="text-dim text-sm">No memories yet — the moment Vestige starts remembering, your constellation will bloom here.</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div class="text-center space-y-4 max-w-md px-8">
|
||||
<div class="text-5xl opacity-30">◎</div>
|
||||
<h2 class="text-xl text-bright">Your Mind Awaits</h2>
|
||||
<div class="text-center space-y-4 max-w-md px-8 enter">
|
||||
<div class="mx-auto w-fit text-synapse-glow opacity-50 breathe"><Icon name="graph" size={52} strokeWidth={1.2} /></div>
|
||||
<h2 class="text-xl text-bright text-aurora">Your Mind Awaits</h2>
|
||||
<p class="text-dim text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -262,34 +283,38 @@ disown</code>
|
|||
{/if}
|
||||
|
||||
<!-- Top controls bar -->
|
||||
<div class="absolute top-4 left-4 right-4 z-10 flex items-center gap-3">
|
||||
<div
|
||||
class="absolute top-0 left-0 right-0 z-10 flex flex-wrap items-center gap-2 px-3 pt-3 sm:top-4 sm:left-4 sm:right-4 sm:gap-3 sm:p-0
|
||||
[padding-top:max(0.75rem,env(safe-area-inset-top))] [padding-left:max(0.75rem,env(safe-area-inset-left))] [padding-right:max(0.75rem,env(safe-area-inset-right))]
|
||||
sm:[padding-top:0] sm:[padding-left:0] sm:[padding-right:0]"
|
||||
>
|
||||
<!-- Search -->
|
||||
<div class="flex gap-2 flex-1 max-w-md">
|
||||
<div class="flex gap-2 w-full sm:flex-1 sm:w-auto sm:max-w-md">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Center graph on..."
|
||||
bind:value={searchQuery}
|
||||
onkeydown={(e) => e.key === 'Enter' && searchGraph()}
|
||||
class="flex-1 px-3 py-2 glass rounded-xl text-text text-sm
|
||||
class="flex-1 min-w-0 px-3 py-2 glass rounded-xl text-text text-sm
|
||||
placeholder:text-muted focus:outline-none focus:!border-synapse/40 transition"
|
||||
/>
|
||||
<button onclick={searchGraph}
|
||||
class="px-3 py-2 bg-synapse/20 border border-synapse/40 text-synapse-glow text-sm rounded-xl hover:bg-synapse/30 transition backdrop-blur-sm">
|
||||
class="shrink-0 min-h-10 px-3 py-2 bg-synapse/20 border border-synapse/40 text-synapse-glow text-sm rounded-xl hover:bg-synapse/30 transition backdrop-blur-sm">
|
||||
Focus
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 ml-auto">
|
||||
<div class="flex flex-wrap gap-2 w-full sm:w-auto sm:ml-auto">
|
||||
<!-- v2.0.8: colour mode toggle. Switches sphere tint between node type
|
||||
(fact / concept / event / …) and FSRS memory state (active / dormant /
|
||||
silent / unavailable). Legend auto-renders in state mode. -->
|
||||
<div class="flex glass rounded-xl p-0.5 text-xs" role="radiogroup" aria-label="Colour mode">
|
||||
<div class="flex shrink min-w-0 glass rounded-xl p-0.5 text-xs" role="radiogroup" aria-label="Colour mode">
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={colorMode === 'type'}
|
||||
onclick={() => (colorMode = 'type')}
|
||||
class="px-3 py-1.5 rounded-lg transition {colorMode === 'type' ? 'bg-synapse/25 text-synapse-glow' : 'text-dim hover:text-text'}"
|
||||
class="min-h-9 px-3 py-1.5 rounded-lg transition {colorMode === 'type' ? 'bg-synapse/25 text-synapse-glow' : 'text-dim hover:text-text'}"
|
||||
title="Colour by node type (fact, concept, event, …)"
|
||||
>
|
||||
Type
|
||||
|
|
@ -299,7 +324,7 @@ disown</code>
|
|||
role="radio"
|
||||
aria-checked={colorMode === 'state'}
|
||||
onclick={() => (colorMode = 'state')}
|
||||
class="px-3 py-1.5 rounded-lg transition {colorMode === 'state' ? 'bg-synapse/25 text-synapse-glow' : 'text-dim hover:text-text'}"
|
||||
class="min-h-9 px-3 py-1.5 rounded-lg transition {colorMode === 'state' ? 'bg-synapse/25 text-synapse-glow' : 'text-dim hover:text-text'}"
|
||||
title="Colour by FSRS memory state (active / dormant / silent / unavailable)"
|
||||
>
|
||||
State
|
||||
|
|
@ -309,26 +334,26 @@ disown</code>
|
|||
role="radio"
|
||||
aria-checked={colorMode === 'ahagraph'}
|
||||
onclick={() => (colorMode = 'ahagraph')}
|
||||
class="px-3 py-1.5 rounded-lg transition {colorMode === 'ahagraph' ? 'bg-synapse/25 text-synapse-glow' : 'text-dim hover:text-text'}"
|
||||
class="min-h-9 px-3 py-1.5 rounded-lg transition {colorMode === 'ahagraph' ? 'bg-synapse/25 text-synapse-glow' : 'text-dim hover:text-text'}"
|
||||
title="Colour by AhaGraph tags (aha / confusion / failure)"
|
||||
>
|
||||
AhaGraph
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Node count -->
|
||||
<select bind:value={maxNodes} onchange={() => loadGraph()}
|
||||
class="px-2 py-2 glass rounded-xl text-dim text-xs">
|
||||
<option value={50}>50 nodes</option>
|
||||
<option value={100}>100 nodes</option>
|
||||
<option value={150}>150 nodes</option>
|
||||
<option value={200}>200 nodes</option>
|
||||
</select>
|
||||
<!-- Node count — clear animated Dropdown (replaces native <select>) -->
|
||||
<Dropdown
|
||||
options={nodeCountOptions}
|
||||
bind:value={maxNodesChoice}
|
||||
icon="graph"
|
||||
class="shrink-0"
|
||||
onChange={onMaxNodesChange}
|
||||
/>
|
||||
|
||||
<!-- Brightness slider (persists in localStorage). Scales node emissive,
|
||||
glow, and distance-compensated fog falloff. Default 1.0, range 0.5-2.5. -->
|
||||
<label
|
||||
class="flex items-center gap-2 px-3 py-2 glass rounded-xl text-dim text-xs select-none"
|
||||
class="flex shrink-0 items-center gap-2 min-h-10 px-3 py-2 glass rounded-xl text-dim text-xs select-none"
|
||||
title="Adjust graph brightness ({graphState.brightness.toFixed(1)}x). Combines with auto distance compensation."
|
||||
>
|
||||
<span class="text-synapse-glow">☀</span>
|
||||
|
|
@ -350,23 +375,41 @@ disown</code>
|
|||
<button
|
||||
onclick={triggerDream}
|
||||
disabled={isDreaming}
|
||||
class="px-4 py-2 rounded-xl bg-dream/20 border border-dream/40 text-dream-glow text-sm
|
||||
class="shrink-0 inline-flex items-center gap-2 min-h-10 px-4 py-2 rounded-xl bg-dream/20 border border-dream/40 text-dream-glow text-sm
|
||||
hover:bg-dream/30 transition-all backdrop-blur-sm disabled:opacity-50
|
||||
{isDreaming ? 'glow-dream animate-pulse-glow' : ''}"
|
||||
>
|
||||
{isDreaming ? '◈ Dreaming...' : '◈ Dream'}
|
||||
<span class={isDreaming ? 'breathe' : ''}><Icon name="dreams" size={16} /></span>
|
||||
{isDreaming ? 'Dreaming…' : 'Dream'}
|
||||
</button>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
MEMORY CINEMA — PROTECTED · DO NOT MODIFY
|
||||
The AI-narrated flythrough. Its trigger + props are flawless
|
||||
and intentionally untouched by the graph-control overhaul.
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
{#if displayNodes.length > 0}
|
||||
<MemoryCinema
|
||||
nodes={displayNodes}
|
||||
edges={displayEdges}
|
||||
centerId={graphData?.center_id ?? ''}
|
||||
/>
|
||||
{/if}
|
||||
<!-- ═══════════ END PROTECTED MEMORY CINEMA ═══════════ -->
|
||||
|
||||
<!-- Reload -->
|
||||
<button onclick={() => loadGraph()}
|
||||
class="px-3 py-2 glass rounded-xl text-dim text-sm hover:text-text transition">
|
||||
↻
|
||||
class="shrink-0 min-h-10 min-w-10 inline-flex items-center justify-center px-3 py-2 glass rounded-xl text-dim hover:text-text hover:rotate-180 transition-all duration-500"
|
||||
title="Reload graph"
|
||||
aria-label="Reload graph"
|
||||
>
|
||||
<Icon name="pulse" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom stats -->
|
||||
<div class="absolute bottom-4 left-4 z-10 text-xs text-dim glass rounded-xl px-3 py-2">
|
||||
<div class="graph-stats-pill absolute bottom-4 left-4 z-10 text-xs text-dim glass rounded-xl px-3 py-2">
|
||||
{#if graphData}
|
||||
<span>{liveNodeCount} nodes</span>
|
||||
<span class="mx-2 text-subtle">·</span>
|
||||
|
|
@ -487,9 +530,9 @@ disown</code>
|
|||
<!-- Explore from this node -->
|
||||
<a
|
||||
href="{base}/explore"
|
||||
class="block text-center px-3 py-2 rounded-xl bg-dream/10 text-dream-glow text-xs hover:bg-dream/20 transition border border-dream/20"
|
||||
class="flex items-center justify-center gap-2 px-3 py-2 rounded-xl bg-dream/10 text-dream-glow text-xs hover:bg-dream/20 transition border border-dream/20"
|
||||
>
|
||||
◬ Explore Connections
|
||||
<Icon name="explore" size={14} /> Explore Connections
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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)}% > 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)}% < 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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { goto, onNavigate } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import {
|
||||
websocket,
|
||||
|
|
@ -18,6 +18,7 @@
|
|||
import AmbientAwarenessStrip from '$lib/components/AmbientAwarenessStrip.svelte';
|
||||
import VerdictBar from '$lib/components/VerdictBar.svelte';
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import Icon, { type IconName } from '$lib/components/Icon.svelte';
|
||||
import { initTheme } from '$stores/theme';
|
||||
|
||||
let { children } = $props();
|
||||
|
|
@ -80,23 +81,39 @@
|
|||
};
|
||||
});
|
||||
|
||||
const nav = [
|
||||
{ href: '/graph', label: 'Graph', icon: '◎', shortcut: 'G' },
|
||||
{ href: '/reasoning', label: 'Reasoning', icon: '✦', shortcut: 'R' },
|
||||
{ href: '/memories', label: 'Memories', icon: '◈', shortcut: 'M' },
|
||||
{ href: '/timeline', label: 'Timeline', icon: '◷', shortcut: 'T' },
|
||||
{ href: '/feed', label: 'Feed', icon: '◉', shortcut: 'F' },
|
||||
{ href: '/explore', label: 'Explore', icon: '◬', shortcut: 'E' },
|
||||
{ href: '/activation', label: 'Activation', icon: '◈', shortcut: 'A' },
|
||||
{ href: '/dreams', label: 'Dreams', icon: '✧', shortcut: 'D' },
|
||||
{ href: '/schedule', label: 'Schedule', icon: '◷', shortcut: 'C' },
|
||||
{ href: '/importance', label: 'Importance', icon: '◎', shortcut: 'P' },
|
||||
{ href: '/duplicates', label: 'Duplicates', icon: '◉', shortcut: 'U' },
|
||||
{ href: '/contradictions', label: 'Contradictions', icon: '⚠', shortcut: 'X' },
|
||||
{ href: '/patterns', label: 'Patterns', icon: '▦', shortcut: 'N' },
|
||||
{ href: '/intentions', label: 'Intentions', icon: '◇', shortcut: 'I' },
|
||||
{ href: '/stats', label: 'Stats', icon: '◫', shortcut: 'S' },
|
||||
{ href: '/settings', label: 'Settings', icon: '⚙', shortcut: ',' },
|
||||
// Native View Transitions for client-side route navigation. Crossfades route
|
||||
// changes when supported; respects prefers-reduced-motion. This replaces the
|
||||
// old hand-rolled .animate-page-in keyframe on the route content wrapper.
|
||||
onNavigate((navigation) => {
|
||||
if (!document.startViewTransition || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
return new Promise((resolve) => {
|
||||
document.startViewTransition(async () => {
|
||||
resolve();
|
||||
await navigation.complete;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Each nav item carries a UNIQUE semantic icon (see Icon.svelte). The old
|
||||
// set reused the same Unicode glyph across multiple items; every entry here
|
||||
// now has a distinct silhouette that reads instantly.
|
||||
const nav: { href: string; label: string; icon: IconName; shortcut: string }[] = [
|
||||
{ href: '/graph', label: 'Graph', icon: 'graph', shortcut: 'G' },
|
||||
{ href: '/reasoning', label: 'Reasoning', icon: 'reasoning', shortcut: 'R' },
|
||||
{ href: '/memories', label: 'Memories', icon: 'memories', shortcut: 'M' },
|
||||
{ href: '/timeline', label: 'Timeline', icon: 'timeline', shortcut: 'T' },
|
||||
{ href: '/feed', label: 'Feed', icon: 'feed', shortcut: 'F' },
|
||||
{ href: '/explore', label: 'Explore', icon: 'explore', shortcut: 'E' },
|
||||
{ href: '/activation', label: 'Activation', icon: 'activation', shortcut: 'A' },
|
||||
{ href: '/dreams', label: 'Dreams', icon: 'dreams', shortcut: 'D' },
|
||||
{ href: '/schedule', label: 'Schedule', icon: 'schedule', shortcut: 'C' },
|
||||
{ href: '/importance', label: 'Importance', icon: 'importance', shortcut: 'P' },
|
||||
{ href: '/duplicates', label: 'Duplicates', icon: 'duplicates', shortcut: 'U' },
|
||||
{ href: '/contradictions', label: 'Contradictions', icon: 'contradictions', shortcut: 'X' },
|
||||
{ href: '/patterns', label: 'Patterns', icon: 'patterns', shortcut: 'N' },
|
||||
{ href: '/intentions', label: 'Intentions', icon: 'intentions', shortcut: 'I' },
|
||||
{ href: '/stats', label: 'Stats', icon: 'stats', shortcut: 'S' },
|
||||
{ href: '/settings', label: 'Settings', icon: 'settings', shortcut: ',' },
|
||||
];
|
||||
|
||||
// Mobile nav shows top 5 items
|
||||
|
|
@ -135,11 +152,11 @@
|
|||
<!-- Desktop Sidebar (hidden on mobile) -->
|
||||
<nav class="hidden md:flex w-16 lg:w-56 flex-shrink-0 glass-sidebar flex-col">
|
||||
<!-- Logo -->
|
||||
<a href="{base}/graph" class="flex items-center gap-3 px-4 py-5 border-b border-synapse/10">
|
||||
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-dream to-synapse flex items-center justify-center text-bright text-sm font-bold shadow-lg shadow-synapse/20">
|
||||
V
|
||||
<a href="{base}/graph" class="logo-link flex items-center gap-3 px-4 py-5 border-b border-synapse/10">
|
||||
<div class="logo-mark w-8 h-8 rounded-lg bg-gradient-to-br from-dream to-synapse flex items-center justify-center text-bright shadow-lg shadow-synapse/20">
|
||||
<Icon name="logo" size={18} strokeWidth={1.8} />
|
||||
</div>
|
||||
<span class="hidden lg:block text-sm font-semibold text-bright tracking-wide">VESTIGE</span>
|
||||
<span class="hidden lg:block text-sm font-semibold text-bright tracking-[0.18em]">VESTIGE</span>
|
||||
</a>
|
||||
|
||||
<!-- Nav items -->
|
||||
|
|
@ -148,12 +165,14 @@
|
|||
{@const active = isActive(item.href, $page.url.pathname)}
|
||||
<a
|
||||
href="{base}{item.href}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 text-sm
|
||||
class="nav-link group flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 text-sm
|
||||
{active
|
||||
? 'bg-synapse/15 text-synapse-glow border border-synapse/30 shadow-[0_0_12px_rgba(99,102,241,0.15)] nav-active-border'
|
||||
: 'text-dim hover:text-text hover:bg-white/[0.03] border border-transparent'}"
|
||||
>
|
||||
<span class="text-base w-5 text-center">{item.icon}</span>
|
||||
<span class="nav-icon w-5 flex justify-center transition-transform duration-200 group-hover:scale-110">
|
||||
<Icon name={item.icon} size={18} />
|
||||
</span>
|
||||
<span class="hidden lg:block">{item.label}</span>
|
||||
<span class="hidden lg:block ml-auto text-[10px] text-muted/50 font-mono">{item.shortcut}</span>
|
||||
</a>
|
||||
|
|
@ -166,8 +185,9 @@
|
|||
onclick={() => { showCommandPalette = true; cmdQuery = ''; requestAnimationFrame(() => cmdInput?.focus()); }}
|
||||
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-muted hover:text-dim hover:bg-white/[0.03] transition border border-subtle/15"
|
||||
>
|
||||
<span class="text-[10px] font-mono bg-white/[0.04] px-1.5 py-0.5 rounded">⌘K</span>
|
||||
<Icon name="command" size={14} />
|
||||
<span class="hidden lg:block">Command</span>
|
||||
<span class="hidden lg:block ml-auto text-[10px] font-mono bg-white/[0.04] px-1.5 py-0.5 rounded">⌘K</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -201,7 +221,7 @@
|
|||
<main class="flex-1 flex flex-col min-h-0 pb-16 md:pb-0">
|
||||
<AmbientAwarenessStrip />
|
||||
<VerdictBar />
|
||||
<div class="animate-page-in flex-1 min-h-0 overflow-y-auto">
|
||||
<div class="flex-1 min-h-0 overflow-y-auto">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
|
|
@ -216,7 +236,7 @@
|
|||
class="flex flex-col items-center gap-0.5 px-3 py-2 rounded-lg transition-all min-w-[3.5rem]
|
||||
{active ? 'text-synapse-glow' : 'text-muted'}"
|
||||
>
|
||||
<span class="text-lg">{item.icon}</span>
|
||||
<Icon name={item.icon} size={20} />
|
||||
<span class="text-[9px]">{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
|
|
@ -246,7 +266,7 @@
|
|||
>
|
||||
<div class="w-full max-w-lg glass-panel rounded-xl shadow-2xl shadow-synapse/10 overflow-hidden">
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-synapse/10">
|
||||
<span class="text-synapse text-sm">◎</span>
|
||||
<span class="text-synapse"><Icon name="search" size={16} /></span>
|
||||
<input
|
||||
bind:this={cmdInput}
|
||||
bind:value={cmdQuery}
|
||||
|
|
@ -267,7 +287,7 @@
|
|||
onclick={() => cmdNavigate(item.href)}
|
||||
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-dim hover:text-text hover:bg-white/[0.04] transition"
|
||||
>
|
||||
<span class="text-base w-5 text-center">{item.icon}</span>
|
||||
<span class="w-5 flex justify-center"><Icon name={item.icon} size={17} /></span>
|
||||
<span>{item.label}</span>
|
||||
<span class="ml-auto text-[10px] text-muted/50 font-mono hidden md:block">{item.shortcut}</span>
|
||||
</button>
|
||||
|
|
@ -284,11 +304,25 @@
|
|||
.safe-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
@keyframes page-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
|
||||
/* Logo breathes a faint synapse glow on hover — the mark feels live. */
|
||||
.logo-mark {
|
||||
transition:
|
||||
transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
box-shadow 0.3s ease;
|
||||
}
|
||||
.animate-page-in {
|
||||
animation: page-in 0.2s ease-out;
|
||||
.logo-link:hover .logo-mark {
|
||||
transform: rotate(-6deg) scale(1.08);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(129, 140, 248, 0.4),
|
||||
0 0 22px rgba(99, 102, 241, 0.5);
|
||||
}
|
||||
|
||||
/* The active nav item's icon picks up a soft drop-shadow glow so the
|
||||
current location reads at a glance even in the collapsed (icon-only)
|
||||
sidebar. */
|
||||
.nav-link.text-synapse-glow .nav-icon :global(svg),
|
||||
.nav-active-border .nav-icon :global(svg) {
|
||||
filter: drop-shadow(0 0 6px rgba(129, 140, 248, 0.55));
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
172
docs/MEMORY_CINEMA.md
Normal file
172
docs/MEMORY_CINEMA.md
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
# Memory Cinema — Complete Feature Reference
|
||||
|
||||
Memory Cinema turns your real memory graph into a directed, narrated, infinitely-
|
||||
diving cinematic experience rendered as a 150,000-particle WebGPU compute storm.
|
||||
It is the dashboard's signature pillar.
|
||||
|
||||
The whole thing is **dynamically imported only on launch** — the heavy WebGPU/TSL
|
||||
bundles never load for normal dashboard use. It boots a **separate WebGPU canvas**
|
||||
so the underlying WebGL graph (every current user's experience) is never touched —
|
||||
zero regression by construction.
|
||||
|
||||
## Architecture — the 8 modules
|
||||
|
||||
| Module | Role |
|
||||
|---|---|
|
||||
| `pathfinder.ts` | Plans the narrative path through the real graph (a story, not a BFS dump) |
|
||||
| `topology.ts` | Extracts graph signals (betweenness, contradictions, surprise, decay) |
|
||||
| `auteur.ts` | The director's brain — a typed cinematography grammar + shot-plan contract |
|
||||
| `narrator.ts` | Captions — 3-tier narration (LLM / local / deterministic) |
|
||||
| `director.ts` | The camera runtime — executes the shot plan frame-by-frame |
|
||||
| `sandbox.ts` | The isolated WebGPU stage — renderer, camera, selective bloom |
|
||||
| `storm.ts` | The 150k-particle GPU compute storm — physics, geometry, color, immersion |
|
||||
| `components/MemoryCinema.svelte` | The orchestration overlay — input, dream mode, UI |
|
||||
|
||||
## Tier 1 — the narrative path (`pathfinder.ts`)
|
||||
|
||||
The bulletproof core that always runs, using only the nodes + edges the backend
|
||||
returns. It plans a story: start at the origin (focused memory) → visit its
|
||||
strongest-weighted connections → detour to a contradiction edge if one exists
|
||||
(tension = interesting) → end on a recently-created node ("where the mind is now").
|
||||
Each stop is a beat with a `kind` (origin/connection/contradiction/recent/bridge/
|
||||
surprise). Falls back to weighted BFS. No LLM, no WebGPU, no network — if
|
||||
everything else fails this alone produces a coherent, watchable flythrough.
|
||||
|
||||
## Tier 2 — graph signal extraction (`topology.ts`)
|
||||
|
||||
Pure statistics over the real `/api/graph` data, computed once per launch:
|
||||
- **Brandes betweenness centrality** — the most load-bearing memory (graph keystone)
|
||||
- **Connected-component clustering**
|
||||
- **Contradiction detection** + **merge/supersede detection**
|
||||
- **Surprise score** (shared neighbors yet low edge weight = non-obvious link)
|
||||
- **Recency rank**, **FSRS retention**, **suppression pressure**
|
||||
|
||||
## The Auteur — the director's brain (`auteur.ts`)
|
||||
|
||||
A real cinematography grammar applied to your memories. The director (LLM Tier-1 or
|
||||
a deterministic rule table Tier-2) produces a `DirectorPlan`: typed `Shot`s, each
|
||||
grounded in a real node and justified by a real metric.
|
||||
- **Moves:** push_in, pull_back, orbit, crane, whip_pan, rack_focus, hold
|
||||
- **Angles:** low = look up (power), high = look down (decay), eye
|
||||
- **Cuts:** fly, hard_cut, match_cut
|
||||
- **Per shot:** dutch roll, standoff, flight/dwell seconds, spring half-life,
|
||||
intensity, tension (0–1)
|
||||
- **Three acts** (I/II/III), an **emotional arc** (man_in_hole, rags_to_riches,
|
||||
icarus, cinderella, oedipus, flat), a **caption tone**, a **score cue**
|
||||
- A **required `why`** per shot citing the real graph metric
|
||||
- **Carry-forward semantics:** sparse/half-hallucinated plans always resolve to a
|
||||
coherent film.
|
||||
|
||||
## Narration (`narrator.ts`)
|
||||
|
||||
- **Tier 1:** backend LLM (`/api/narrative`) or opt-in on-device model (lazy-loaded
|
||||
only when "Local AI" enabled; never downloads weights unprompted).
|
||||
- **Tier 2:** deterministic structured captions from real data — instant, no network.
|
||||
- **Tier 3:** can't fail — falls back to Tier 2.
|
||||
- Optional voice via `speechSynthesis`; typewriter caption stream (instant under
|
||||
reduced-motion).
|
||||
|
||||
## The 150k-particle storm (`storm.ts`)
|
||||
|
||||
150,000 particles whose physics run entirely on the GPU via Three Shading Language
|
||||
compute nodes. One particle pool, one compute kernel, ~32 uniforms.
|
||||
|
||||
### The 7-beat world journey
|
||||
Each beat is a unique world — particles aren't swapped, only the forces (uWorld
|
||||
selects; uBlend crossfades over ~1s):
|
||||
0 nebula mist (curl-noise flow) · 1 orbital anchor (cross-product spin) · 2 strange
|
||||
attractor (Thomas) · 3 detonation void · 4 crystal lattice (voxel snap) · 5 fluid
|
||||
galaxy (curl + swirl) · 6 phyllotaxis bloom (Vogel sunflower).
|
||||
|
||||
### The impossible-geometry form pack (dream worlds 7–11)
|
||||
Forms nobody renders as living particles, mapped over a (u,v) manifold grid (387²)
|
||||
so they read as a sculpted skin, not spaghetti:
|
||||
7 supershape (3D superformula) · 8 Calabi–Yau (6D string-theory manifold, 4D→3D
|
||||
projection; α rotates through the 4th dimension) · 9 Boy's surface (non-orientable
|
||||
minimal immersion) · 10 Aizawa attractor shell · 11 gyroid↔Schwarz-D (triply-
|
||||
periodic minimal surface morph). Inline complex-math + hyperbolics (sinh/cosh
|
||||
expanded via exp; absent in three@0.172).
|
||||
|
||||
### Color
|
||||
- Full-spectrum iridescent palette (per-particle phase + radial shells + spatial
|
||||
bands + time + global drift).
|
||||
- Per-world cosine (IQ) palettes — each world a distinct identity.
|
||||
- **The Color Blast:** a long-lived uBlast envelope (~2.8s, decoupled from the fast
|
||||
physics burst so color outlives the shockwave) drives an outward spectral-
|
||||
dispersion wave (uBlastTime; prism order) over a blackbody ember core. Spectrum
|
||||
dominates (reads rainbow, not white); capped at 0.6 mix.
|
||||
- **Jarring inner/outer clash:** the nested figure and shell use opposing color
|
||||
universes (ice↔fire, acid↔blood, gold↔violet, mint↔crimson, electric↔gold);
|
||||
uClash cycles the pair every beat.
|
||||
|
||||
### 3D-within-3D nesting
|
||||
~34% of particles form a second, smaller, counter-rotating figure (a different
|
||||
world, ~52% scale) inside the outer shell — a figure within a figure.
|
||||
|
||||
### Anti-white-out / "solid" systems
|
||||
- Rim glow (Fresnel): bright edges, dim readable center.
|
||||
- Emissive routing: the rainbow goes to BOTH colorNode and emissiveNode (the
|
||||
selective bloom reads emissive — the original white-out was an unset emissive).
|
||||
- Hollow-shell spawn (the old tiny dense ball flashed white on frame 0).
|
||||
- Act/beat-aware brightness (uActDim): beats 0/1 fade in soft, Acts II/III blaze.
|
||||
|
||||
### The immersion stack
|
||||
- **Infinite Droste zoom** (uZoomPeriod/uLambda/uZoomOn): the cloud dives inward
|
||||
forever. Two layers ride offset phases of fract(uTime/T); the outer grows
|
||||
pow(λ, phase) then snaps back invisibly (λ=1.923=1/0.52 makes inner→outer exact);
|
||||
the inner promotes by λ to become the next shell; a fresh inner spawns inside. A
|
||||
sin(phase·π) seam cross-fade in rimFactor makes each layer transparent at its
|
||||
snap → zero pop, seamless loop. Particle-space, not a camera dolly.
|
||||
- **Velocity-stretch flythrough** (uCamVelView/uStreak/uMaxStretch): sprites stretch
|
||||
along screen-space camera velocity into warp streaks; the camera clamp floor
|
||||
relaxes (30→6) so it plunges inside the shell.
|
||||
- **DOF + volumetric fog** (uFocus/uFocusRange/uDofDim/uFogDensity): off-focus
|
||||
particles dim (bokeh under bloom) + exp-falloff fog tints the far field → 3D
|
||||
atmospheric depth; rack-focus tracks the dive. All folded into a single depthFade
|
||||
Fn (three@0.172 constraint: positionView may be read from only ONE Fn per
|
||||
material output or the node-builder stack-overflows).
|
||||
- **Interactive parallax** (overlay): pointer orbits, scroll/pinch zooms, damped;
|
||||
composed onto the director's base pose; idle eases back to 0; the sandbox re-clamp
|
||||
means framing can't break.
|
||||
|
||||
### Physics safety
|
||||
Hard velocity clamp, strong damping, boundary snap, dt clamp, serialized compute
|
||||
dispatches.
|
||||
|
||||
## Endless dream mode
|
||||
|
||||
When the 7-beat tour ends, instead of freezing it drops into an infinite generative
|
||||
loop (`dreamBeat()` every ~5.5s): a fresh random procedural figure (worlds 7–11, new
|
||||
uMorphSeed → never the same twice), uChaos ramps up so each is wilder than the last,
|
||||
a random clash pair, full color blast, infinite zoom + flythrough on. Overlay shows
|
||||
"∞ Dreaming." Never idle.
|
||||
|
||||
## The stage (`sandbox.ts`)
|
||||
|
||||
- Separate WebGPU renderer + scene + PerspectiveCamera (clamped to a safe distance
|
||||
band, always lookAt(origin) → the storm can never leave frame).
|
||||
- Selective MRT bloom — blooms only the emissive channel against a clean void; falls
|
||||
back to a plain pass if MRT is unavailable on a driver.
|
||||
- Per-frame camera-velocity tracking (one Vector3, zero compute) feeding the streak.
|
||||
- Pass-throughs: setZoom, setStreak, setFlythrough, setCameraVel, setContainRadius.
|
||||
|
||||
## Overlay & UX (`MemoryCinema.svelte`)
|
||||
|
||||
- Fullscreen launch, staged status (idle → planning → playing → done).
|
||||
- Pre-roll "Director's Plan" card (logline + arc).
|
||||
- Live captions, beat chips, director's-note ("why," citing a real metric), act
|
||||
indicator, tension sparkline, progress bar.
|
||||
- WebGPU / Auteur / Live-captions badges; Voice + Local AI toggles.
|
||||
- `H` hides all chrome for clean demo capture (faint restore hint; `body.cinema-open`
|
||||
hides the graph page's stats pill; overlay z-index 200).
|
||||
- Replay on completion.
|
||||
- Graceful degradation: if WebGPU is unavailable or render fails 3× consecutively,
|
||||
drops to camera-only (captions still play) — never stalls.
|
||||
- Reduced-motion fully honored: no parallax listeners, zoom/flythrough/streak gated
|
||||
off, jump-cuts instead of flights, instant captions.
|
||||
|
||||
## Protection contract
|
||||
|
||||
On the shared graph page, `<MemoryCinema>` is wrapped in a clearly-commented
|
||||
`PROTECTED MEMORY CINEMA — DO NOT MODIFY` boundary so the graph-control overhaul and
|
||||
the cinema engine stay visibly distinct.
|
||||
Loading…
Add table
Add a link
Reference in a new issue