feat(dashboard): Phase 2 — alive shell, translucent panels, living dead-states

Make the WebGPU violet field breathe THROUGH the UI and turn the dead
offline/empty/error states into intentional, alive moments:

- Glass veil-ramp: panel tops drop to ~0.30 opacity so the field crests through
  every header, while body text composites over a ~0.82 floor — more alive AND
  more legible than the old flat 0.45. New .veil/.veil-strong/.text-legible
  legibility utilities.
- edge-live membrane: a state-colored synapse rim (indigo idle, synapse live,
  dream violet, amber alarm) + rail-dormant desaturate when offline. Shell mood
  derived from connection/dream state.
- LifeState component replaces raw "Error: API 500"/"Offline"/"Select a run":
  a calm "Dormant" breathing state, a self-healing error with the raw detail
  demoted to a collapsed <details> + Retry, and a "Listening for the agent"
  offline state wired to the firewall demo.
- Legibility fixes from the a11y audit: sidebar metrics text-muted->text-dim,
  LifeState body->text, detail->dim + veil-strong, ambient strip + text-legible.

All new animation prefers-reduced-motion guarded. Memory Cinema, Black Box
machinery, Graph3D, and the backdrop core untouched. svelte-check 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-06-27 11:36:28 -05:00
parent a68895482d
commit f5ce349cac
5 changed files with 415 additions and 97 deletions

View file

@ -106,44 +106,71 @@ body {
GLASSMORPHISM SYSTEM
*/
/* ── Veil + living-edge tokens — one source of truth ──────────────── */
:root {
/* Veil ramp: field shows through the TOP of a panel, text reads below */
--veil-top: rgba(12, 12, 32, 0.30); /* field-visible band */
--veil-body: rgba(12, 12, 32, 0.82); /* opaque text floor */
--veil-ramp: 64px; /* where the ramp goes opaque */
/* State-colored panel rims (the "nervous system") */
--edge-quiet: rgba(99, 102, 241, 0.22); /* idle synapse rim */
--edge-live: rgba(129, 140, 248, 0.55); /* connected / active */
--edge-dream: rgba(192, 132, 252, 0.50); /* dreaming */
--edge-alarm: rgba(245, 158, 11, 0.55); /* offline/error = AMBER, not red */
--glass-blur: 16px;
}
.glass {
background: rgba(22, 22, 56, 0.45);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(99, 102, 241, 0.08);
/* was rgba(22,22,56,0.45) flat → veil ramp: field bleeds through top 64px */
background: linear-gradient(180deg, var(--veil-top) 0%, var(--veil-body) var(--veil-ramp));
backdrop-filter: blur(var(--glass-blur)) saturate(195%);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(195%);
border: 1px solid var(--edge-quiet);
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, 0.03),
0 4px 24px rgba(0, 0, 0, 0.3);
inset 0 1px 0 0 rgba(168, 130, 255, 0.08), /* top violet sheen */
inset 0 0 24px -16px var(--edge-live), /* faint inner rim-glow */
0 4px 24px rgba(0, 0, 0, 0.30);
}
.glass-subtle {
background: rgba(16, 16, 42, 0.4);
backdrop-filter: blur(12px) saturate(150%);
-webkit-backdrop-filter: blur(12px) saturate(150%);
border: 1px solid rgba(99, 102, 241, 0.06);
background: linear-gradient(180deg, rgba(10, 10, 30, 0.28) 0%, rgba(10, 10, 30, 0.74) 56px);
backdrop-filter: blur(12px) saturate(165%);
-webkit-backdrop-filter: blur(12px) saturate(165%);
border: 1px solid var(--edge-quiet);
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, 0.02),
0 2px 12px rgba(0, 0, 0, 0.2);
inset 0 1px 0 0 rgba(168, 130, 255, 0.05),
0 2px 12px rgba(0, 0, 0, 0.20);
}
.glass-sidebar {
background: rgba(10, 10, 26, 0.6);
backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%);
border-right: 1px solid rgba(99, 102, 241, 0.1);
/* nav rail: denser body for the dense nav list, but field still bleeds at top */
background: linear-gradient(180deg, rgba(14, 12, 34, 0.58) 0%, rgba(9, 8, 24, 0.52) 120px);
backdrop-filter: blur(22px) saturate(175%);
-webkit-backdrop-filter: blur(22px) saturate(175%);
border-right: 1px solid rgba(129, 140, 248, 0.14);
box-shadow:
inset -1px 0 0 0 rgba(255, 255, 255, 0.02),
4px 0 24px rgba(0, 0, 0, 0.3);
inset -1px 0 0 0 rgba(168, 130, 255, 0.05),
inset 0 0 60px -20px var(--edge-live),
4px 0 28px rgba(0, 0, 0, 0.30);
}
.glass-panel {
background: rgba(10, 10, 26, 0.8);
backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%);
border: 1px solid rgba(99, 102, 241, 0.1);
/* was 0.80 flat (worst offender) — modal/command-palette depth without going opaque */
background: linear-gradient(180deg, var(--veil-top) 0%, rgba(10, 10, 26, 0.86) 72px);
backdrop-filter: blur(26px) saturate(185%);
-webkit-backdrop-filter: blur(26px) saturate(185%);
border: 1px solid rgba(129, 140, 248, 0.16);
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, 0.03),
0 8px 32px rgba(0, 0, 0, 0.4);
inset 0 1px 0 0 rgba(168, 130, 255, 0.08),
inset 0 0 40px -18px var(--edge-live),
0 8px 36px rgba(0, 0, 0, 0.42);
}
/* Mobile: drop blur cost on the densest panel over the animated field */
@media (max-width: 640px) {
.glass { backdrop-filter: blur(8px) saturate(180%); -webkit-backdrop-filter: blur(8px) saturate(180%); }
}
/*
@ -173,63 +200,10 @@ body {
animation: pulse-glow 2s ease-in-out infinite;
}
/* Ambient background orbs */
@keyframes orb-float-1 {
0%, 100% { transform: translate(0, 0) scale(1); }
25% { transform: translate(60px, -40px) scale(1.1); }
50% { transform: translate(-30px, -80px) scale(0.95); }
75% { transform: translate(-60px, -20px) scale(1.05); }
}
@keyframes orb-float-2 {
0%, 100% { transform: translate(0, 0) scale(1); }
25% { transform: translate(-50px, 30px) scale(1.08); }
50% { transform: translate(40px, 60px) scale(0.92); }
75% { transform: translate(20px, -40px) scale(1.03); }
}
@keyframes orb-float-3 {
0%, 100% { transform: translate(0, 0) scale(1); }
25% { transform: translate(30px, 50px) scale(1.05); }
50% { transform: translate(-60px, 20px) scale(0.98); }
75% { transform: translate(40px, -30px) scale(1.1); }
}
.ambient-orb {
position: fixed;
border-radius: 50%;
filter: blur(80px);
pointer-events: none;
z-index: 0;
opacity: 0.35;
}
.ambient-orb-1 {
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(168, 85, 247, 0.4), transparent 70%);
top: -10%;
right: -5%;
animation: orb-float-1 20s ease-in-out infinite;
}
.ambient-orb-2 {
width: 350px;
height: 350px;
background: radial-gradient(circle, rgba(99, 102, 241, 0.35), transparent 70%);
bottom: -15%;
left: -5%;
animation: orb-float-2 25s ease-in-out infinite;
}
.ambient-orb-3 {
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(245, 158, 11, 0.2), transparent 70%);
top: 40%;
left: 40%;
animation: orb-float-3 22s ease-in-out infinite;
}
/* Ambient background orbs removed the WebGPU curl-noise field (BackdropEngine)
replaced them. Two ambient systems stacked = muddy. The light-theme
`[data-theme='light'] .ambient-orb` rule in theme.ts is now a harmless no-op
(no DOM node mounts the orbs). */
/* Active nav indicator with animated gradient border */
.nav-active-border {
@ -545,6 +519,95 @@ body {
}
}
/* Local text legibility veil bonds a soft dark plate behind a text
cluster so body copy stays WCAG-AA over the moving field WITHOUT making
the whole panel opaque. Apply to the TEXT wrapper, not the panel.
Use on any text that sits on the field or in a panel's translucent top. */
.veil { position: relative; isolation: isolate; }
.veil::before {
content: '';
position: absolute;
inset: -6px -10px;
z-index: -1;
border-radius: inherit;
background: radial-gradient(120% 100% at 50% 45%,
rgba(8, 7, 22, 0.58), rgba(8, 7, 22, 0.32) 70%, transparent 100%);
pointer-events: none;
}
.veil-strong::before {
inset: -10px -16px;
background: radial-gradient(120% 100% at 50% 45%,
rgba(6, 5, 18, 0.76), rgba(6, 5, 18, 0.46) 65%, transparent 100%);
}
/* Cheap inline fallback for single headings/labels over the field */
.text-legible { text-shadow: 0 1px 10px rgba(5, 3, 13, 0.85), 0 0 2px rgba(5, 3, 13, 0.9); }
/* Living edge: a state-colored synapse membrane on any .glass rim.
Brightens and travels only when the app has something to say. */
@property --edge-pos { syntax: '<percentage>'; initial-value: 0%; inherits: false; }
.edge-live { position: relative; isolation: isolate; }
.edge-live::before {
content: '';
position: absolute;
inset: -1px;
border-radius: inherit;
padding: 1px;
background: linear-gradient(var(--edge-angle, 115deg),
transparent 0%,
var(--edge-c, var(--edge-quiet)) var(--edge-pos),
transparent calc(var(--edge-pos) + 28%));
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
z-index: -1;
}
.edge-live.is-live { --edge-c: var(--edge-live); }
.edge-live.is-dream { --edge-c: var(--edge-dream); --edge-angle: 135deg; }
.edge-live.is-alarm { --edge-c: var(--edge-alarm); }
@media not (prefers-reduced-motion: reduce) {
.edge-live::before { animation: edge-travel 5.5s linear infinite; }
.edge-live.is-alarm::before { animation-duration: 2.6s; }
@keyframes edge-travel { to { --edge-pos: 130%; } }
}
/* Reduced-motion: rim still paints a quiet colored corner at --edge-pos:0% */
/* Dormant mind — EMPTY state: resting but alive, slower/deeper than .breathe */
@keyframes dormant-pulse {
0%, 100% { transform: scale(1); opacity: 0.55; filter: blur(26px); }
50% { transform: scale(1.06); opacity: 0.80; filter: blur(30px); }
}
.dormant-aura { animation: dormant-pulse 6.5s ease-in-out infinite; }
/* Self-healing sweep — ERROR state: a diagnostic scan-line, "repairing itself" */
@keyframes heal-sweep {
0% { transform: translateY(-120%); opacity: 0; }
12% { opacity: 0.9; }
88% { opacity: 0.9; }
100% { transform: translateY(120%); opacity: 0; }
}
/* Reconnect seek — OFFLINE state: a ring that expands and resets, "listening" */
@keyframes seek-ring {
0% { transform: scale(0.7); opacity: 0.7; }
70% { opacity: 0; }
100%{ transform: scale(2.2); opacity: 0; }
}
.seek-host { position: relative; }
.seek-host::before {
content: ''; position: absolute; inset: 0; border-radius: 50%;
background: var(--color-warning); z-index: -1;
}
@media (prefers-reduced-motion: reduce) {
.dormant-aura { animation: none; opacity: 0.6; }
}
@media not (prefers-reduced-motion: reduce) {
.seek-host::before { animation: seek-ring 2.2s ease-out infinite; }
}
/* ── Hover lift utility — cards rise + glow when pointed at ───────────────*/
.lift {
transition:

View file

@ -156,7 +156,7 @@
</script>
<div
class="ambient-strip relative flex h-9 w-full items-center gap-0 overflow-hidden border-b border-synapse/15 bg-black/40 px-3 text-[11px] text-dim backdrop-blur-md"
class="ambient-strip text-legible relative flex h-9 w-full items-center gap-0 overflow-hidden border-b border-synapse/15 px-3 text-[11px] text-dim backdrop-blur-md"
class:ambient-flash={suppressionFlash}
aria-label="Ambient cognitive vitals"
>
@ -309,6 +309,12 @@
</div>
<style>
/* Tinted gradient floor so 11px text-dim stays legible over the live field
(replaces the flat bg-black/40 that could wash dim text on bright frames). */
.ambient-strip {
background: linear-gradient(180deg, rgba(8, 7, 22, 0.62), rgba(8, 7, 22, 0.44));
}
.strip-item {
display: inline-flex;
align-items: center;

View file

@ -0,0 +1,167 @@
<script lang="ts">
import Icon, { type IconName } from './Icon.svelte';
import { reveal } from '$lib/actions/reveal';
let {
variant = 'empty', // 'empty' | 'offline' | 'error' | 'dormant'
title,
body,
detail = '', // raw error string, demoted into <details>
actionLabel = '',
onAction,
compact = false
}: {
variant?: 'empty' | 'offline' | 'error' | 'dormant';
title: string;
body: string;
detail?: string;
actionLabel?: string;
onAction?: () => void;
compact?: boolean;
} = $props();
const glyph: Record<string, IconName> = {
empty: 'graph',
offline: 'activation',
error: 'reasoning',
dormant: 'dreams'
};
const tone: Record<string, string> = {
empty: 'var(--color-synapse-glow)',
offline: 'var(--color-warning)',
error: 'var(--color-warning)',
dormant: 'var(--color-dream-glow)'
};
</script>
<div
class="life-state glass edge-live"
class:is-alarm={variant === 'error' || variant === 'offline'}
class:is-live={variant === 'empty'}
class:is-dream={variant === 'dormant'}
class:compact
data-variant={variant}
style:--tone={tone[variant]}
use:reveal
>
{#if variant === 'error'}<span class="heal" aria-hidden="true"></span>{/if}
<div class="halo" class:dormant-aura={variant === 'dormant'} aria-hidden="true"></div>
<div class="glyph" class:breathe={variant !== 'dormant'} class:seek-host={variant === 'offline'}>
<Icon name={glyph[variant]} size={compact ? 30 : 48} strokeWidth={1.2} />
</div>
<h2 class="title text-aurora veil">{title}</h2>
<p class="body veil">{body}</p>
{#if detail}
<details class="detail veil-strong"><summary>Technical detail</summary><code>{detail}</code></details>
{/if}
{#if actionLabel && onAction}
<button class="act" onclick={onAction}>{actionLabel}</button>
{/if}
</div>
<style>
.life-state {
position: relative;
isolation: isolate;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 14px;
padding: 48px 32px;
border-radius: 16px;
max-width: 32rem;
margin: 8vh auto 0;
}
.life-state.compact {
padding: 22px 18px;
gap: 9px;
margin: 0;
max-width: none;
}
.halo {
position: absolute;
z-index: -1;
width: 240px;
height: 240px;
top: 8px;
border-radius: 50%;
opacity: 0.5;
background: radial-gradient(circle, color-mix(in oklab, var(--tone) 38%, transparent), transparent 70%);
}
.glyph {
color: var(--tone);
display: grid;
place-items: center;
}
.title {
font-size: 1.25rem;
font-weight: 700;
margin: 0;
}
.compact .title {
font-size: 0.95rem;
}
.body {
font-size: 0.86rem;
line-height: 1.55;
/* --color-text, not --color-dim: AA-legible over a bright field cluster */
color: var(--color-text);
max-width: 34ch;
margin: 0;
}
.detail {
font-size: 0.72rem;
/* --color-dim, not --color-muted: muted failed AA even with the veil */
color: var(--color-dim);
}
.detail summary {
cursor: pointer;
}
.detail code {
display: block;
margin-top: 6px;
padding: 8px 10px;
border-radius: 8px;
background: color-mix(in oklab, var(--color-decay) 8%, transparent);
color: #fca5a5;
word-break: break-all;
text-align: left;
}
.act {
margin-top: 6px;
padding: 9px 18px;
border-radius: 10px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
color: var(--color-synapse-glow);
background: color-mix(in oklab, var(--color-synapse) 14%, transparent);
border: 1px solid color-mix(in oklab, var(--color-synapse) 32%, transparent);
transition: background 0.18s ease, transform 0.18s ease;
}
.act:hover {
background: color-mix(in oklab, var(--color-synapse) 24%, transparent);
transform: translateY(-1px);
}
.heal {
position: absolute;
inset-inline: 0;
top: 0;
height: 38%;
z-index: -1;
pointer-events: none;
background: linear-gradient(180deg, transparent,
color-mix(in oklab, var(--color-warning) 22%, transparent), transparent);
}
@media not (prefers-reduced-motion: reduce) {
.heal { animation: heal-sweep 3.4s ease-in-out infinite; }
}
@media (prefers-reduced-motion: reduce) {
.act:hover { transform: none; }
}
</style>

View file

@ -7,12 +7,15 @@
import {
websocket,
isConnected,
isReconnecting,
eventFeed,
memoryCount,
avgRetention,
suppressedCount,
uptimeSeconds,
formatUptime
} from '$stores/websocket';
import { isDreaming as isDreamingFn } from '$lib/components/awareness-helpers';
import ForgettingIndicator from '$lib/components/ForgettingIndicator.svelte';
import BackdropEngine from '$lib/core/BackdropEngine.svelte';
import InsightToast from '$lib/components/InsightToast.svelte';
@ -27,9 +30,17 @@
let cmdQuery = $state('');
let cmdInput = $state<HTMLInputElement>(undefined as unknown as HTMLInputElement);
// One reactive "mood" for the whole shell: offline → alarm, dreaming → dream,
// otherwise → live. Drives the sidebar edge, the strip rule, and the footer.
let nowTick = $state(Date.now());
const shellState = $derived(
!$isConnected ? 'alarm' : isDreamingFn($eventFeed, nowTick) ? 'dream' : 'live'
);
onMount(() => {
websocket.connect();
const teardownTheme = initTheme();
const moodTick = setInterval(() => (nowTick = Date.now()), 1000);
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
@ -81,6 +92,7 @@
websocket.disconnect();
window.removeEventListener('keydown', onKeyDown);
teardownTheme();
clearInterval(moodTick);
};
});
@ -140,7 +152,7 @@
<BackdropEngine />
<div class="flex flex-col md:flex-row h-screen overflow-hidden bg-void relative z-[1]">
<nav class="hidden md:flex w-16 lg:w-56 flex-shrink-0 glass-sidebar flex-col">
<nav class="hidden md:flex w-16 lg:w-56 flex-shrink-0 glass-sidebar flex-col edge-live is-{shellState}" class:rail-dormant={!$isConnected}>
<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} />
@ -184,13 +196,20 @@
<div class="px-3 py-4 border-t border-synapse/10 space-y-2">
<div class="flex items-center gap-2 text-xs">
<div class="w-2 h-2 rounded-full {$isConnected ? 'bg-recall animate-pulse-glow' : 'bg-decay'}"></div>
<span class="hidden lg:block text-dim">{$isConnected ? 'Connected' : 'Offline'}</span>
{#if $isConnected}
<span class="ping-host inline-flex h-2 w-2 rounded-full bg-recall breathe text-recall"></span>
<span class="hidden lg:block text-recall/90 text-legible">Listening</span>
{:else}
<span class="seek-host inline-flex h-2 w-2 rounded-full bg-warning"></span>
<span class="hidden lg:block text-warning/90 text-legible">
{$isReconnecting ? 'Reconnecting…' : 'Asleep — server offline'}
</span>
{/if}
<div class="ml-auto">
<ThemeToggle />
</div>
</div>
<div class="hidden lg:block text-xs text-muted space-y-0.5">
<div class="veil hidden lg:block text-xs text-dim space-y-0.5">
<div>{$memoryCount} memories</div>
<div>{($avgRetention * 100).toFixed(0)}% retention</div>
{#if $uptimeSeconds > 0}
@ -206,7 +225,7 @@
</nav>
<main class="flex-1 flex flex-col min-h-0 pb-16 md:pb-0">
<AmbientAwarenessStrip />
<div class="strip-rail is-{shellState}"><AmbientAwarenessStrip /></div>
<VerdictBar />
<div class="flex-1 min-h-0 overflow-y-auto">
{@render children()}
@ -311,4 +330,37 @@
.nav-active-border .nav-icon :global(svg) {
filter: drop-shadow(0 0 6px rgba(129, 140, 248, 0.55));
}
/* Offline → the whole spine cools and desaturates: a sleeping mind. */
.rail-dormant {
filter: saturate(0.7) brightness(0.93);
transition: filter 0.6s ease;
}
/* The metric strip's bottom rule reacts to shell mood (reuses gradient-shift). */
.strip-rail {
position: relative;
}
.strip-rail::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
background: linear-gradient(90deg, transparent, var(--edge-c, var(--edge-live)) 50%, transparent);
background-size: 220% 100%;
}
.strip-rail.is-dream::after {
--edge-c: var(--edge-dream);
}
.strip-rail.is-alarm::after {
background: var(--color-warning);
}
@media not (prefers-reduced-motion: reduce) {
.strip-rail.is-live::after,
.strip-rail.is-dream::after {
animation: gradient-shift 4s ease-in-out infinite;
}
}
</style>

View file

@ -19,6 +19,7 @@
import AnimatedNumber from '$components/AnimatedNumber.svelte';
import { reveal } from '$lib/actions/reveal';
import ReceiptCard from '$components/ReceiptCard.svelte';
import LifeState from '$components/LifeState.svelte';
import {
api,
type TraceRunSummary,
@ -296,10 +297,12 @@
<aside class="runs glass" use:reveal>
<h2 class="panel-title">Runs</h2>
{#if runs.length === 0}
<p class="empty">
No agent runs recorded yet. Make an MCP tool call — every call is
recorded here.
</p>
<LifeState
variant="dormant"
compact
title="Dormant"
body="No runs yet. The recorder is breathing, waiting for the first tool call."
/>
{:else}
<ul>
{#each runs as run (run.runId)}
@ -332,9 +335,30 @@
{#if loading}
<div class="glass center-msg">Loading trace…</div>
{:else if error}
<div class="glass center-msg err">{error}</div>
<LifeState
variant="error"
title="Reading the flight recorder…"
body="The trace stream hiccuped while pulling this run. Vestige is re-establishing the connection — your runs are safe on disk."
detail={error}
actionLabel="Retry"
onAction={() => selectedRunId && selectRun(selectedRunId)}
/>
{:else if !detail}
<div class="glass center-msg">Select a run to replay.</div>
{#if !$isConnected}
<LifeState
variant="offline"
title="Listening for the agent"
body="No live socket yet. The moment an MCP tool call fires, this recorder lights up and replays it — runId, retrievals, receipts, all of it."
actionLabel="Try the firewall demo"
onAction={() => loadFirewallDemo()}
/>
{:else}
<LifeState
variant="empty"
title="Pick a run to replay"
body="Every MCP tool call is recorded on the left. Select a run to watch the agent think, frame by frame."
/>
{/if}
{:else}
<!-- Scrubber -->
<div class="scrubber glass" use:reveal>
@ -428,7 +452,7 @@
Memory pulse <span class="text-dim">— touched this run</span>
</h3>
{#if pulsedIds.length === 0}
<p class="empty">No memories touched yet.</p>
<p class="empty"><span class="empty-dot">·</span> No memories touched yet — they light up as the run replays.</p>
{:else}
<div class="pulse-grid">
{#each pulsedIds as id (id)}
@ -699,6 +723,15 @@
color: var(--color-text-dim, #8b8ba7);
line-height: 1.5;
}
.empty-dot {
color: var(--color-synapse-glow);
}
@media not (prefers-reduced-motion: reduce) {
.empty-dot {
animation: breathe 3.2s ease-in-out infinite;
display: inline-block;
}
}
.runs {
padding: 16px;
@ -778,9 +811,6 @@
text-align: center;
color: var(--color-text-dim, #8b8ba7);
}
.center-msg.err {
color: #f87171;
}
/* Scrubber */
.scrubber {