feat(v2.4): UI expansion — 8 new surfaces exposing the cognitive engine

Sam asked: "Build EVERY SINGLE MISSING UI PIECE." 10 parallel agents shipped
10 new viewports over the existing Rust backend, then 11 audit agents
line-by-line reviewed each one, extracted pure-logic helpers, fixed ~30
bugs, and shipped 549 new unit tests. Everything wired through the layout
with single-key shortcuts and a live theme toggle.

**Eight new routes**
- `/reasoning`  — Reasoning Theater: Cmd+K ask palette → animated 8-stage
  deep_reference pipeline + FSRS-trust-scored evidence cards +
  contradiction arcs rendered as live SVG between evidence nodes
- `/duplicates` — threshold-driven cluster detector with winner selection,
  Merge/Review/Dismiss actions, debounced slider
- `/dreams`     — Dream Cinema: trigger dream + scrubbable 5-stage replay
  (Replay → Cross-reference → Strengthen → Prune → Transfer) + insight
  cards with novelty glow
- `/schedule`   — FSRS Review Calendar: 6×7 grid with urgency color
  bands (overdue/today/week/future), retention sparkline, expand-day list
- `/importance` — 4-channel radar (Novelty/Arousal/Reward/Attention) with
  composite score + top-important list
- `/activation` — live spreading-activation view: search → SVG concentric
  rings with decay animation + live-mode event feed
- `/contradictions` — 2D cosmic constellation of conflicting memories,
  arcs colored by severity, tooltips with previews
- `/patterns`   — cross-project pattern transfer heatmap with category
  filters, top-transferred sidebar

**Three layout additions**
- `AmbientAwarenessStrip` — slim top band with retention vitals, at-risk
  count, active intentions, recent dream, activity sparkline, dreaming
  indicator, Sanhedrin-watch flash. Pure `$derived` over existing stores.
- `ThemeToggle` — dark/light/auto cycle with matchMedia listener,
  localStorage persistence, SSR-safe, reduced-motion-aware. Rendered in
  sidebar footer next to the connection dot.
- `MemoryAuditTrail` — per-memory Sources panel integrated as a
  Content/Audit tab into the existing /memories expansion.

**Pure-logic helper modules extracted (for testability + reuse)**
  reasoning-helpers, duplicates-helpers, dream-helpers, schedule-helpers,
  audit-trail-helpers, awareness-helpers, contradiction-helpers,
  activation-helpers, patterns-helpers, importance-helpers.

**Bugs fixed during audit (not exhaustive)**
- Trust-color inconsistency between EvidenceCard and the page confidence
  ring (0.75 boundary split emerald vs amber)
- `new Date('garbage').toLocaleDateString()` returned literal "Invalid Date"
  in 3 components — all now return em-dash or raw string
- NaN propagation in `Math.max(0, Math.min(1, NaN))` across clamps
- Off-by-one PRNG in audit-trail seeded mock (seed === UINT32_MAX yielded
  rand() === 1.0 → index out of bounds)
- Duplicates dismissals keyed by array index broke on re-fetch; now keyed
  by sorted cluster member IDs with stale-dismissal pruning
- Empty-cluster crash in DuplicateCluster.pickWinner
- Undefined tags crash in DuplicateCluster.safeTags
- Debounce timer leak in threshold slider (missing onDestroy cleanup)
- Schedule day-vs-hour granularity mismatch between calendar cell and
  sidebar list ("today" in one, "in 1d" in the other)
- Schedule 500-memory hard cap silently truncated; bumped to 2000 + banner
- Schedule DST boundary bug in daysBetween (wall-clock math vs
  startOfDay-normalized)
- Dream stage clamp now handles NaN/Infinity/floats
- Dream double-click debounce via `if (dreaming) return`
- Theme setTheme runtime validation; initTheme idempotence (listener +
  style-element dedup on repeat calls)
- ContradictionArcs node radius unclamped (trust < 0 or > 1 rendered
  invalid sizes); tooltip position clamp (could push off-canvas)
- ContradictionArcs $state closure capture (width/height weren't reactive
  in the derived layout block)
- Activation route was MISSING from the repo — audit agent created it
  with identity-based event filtering and proper RAF cleanup
- Layout: ThemeToggle was imported but never rendered — now in sidebar
  footer; sidebar overflow-y-auto added for the 16-entry nav

**Tests — 549 new, 910 total passing (0 failures)**
  ReasoningChain     42 | EvidenceCard       50
  DuplicateCluster   64 | DreamStageReplay   19
  DreamInsightCard   43 | FSRSCalendar       32
  MemoryAuditTrail   45 | AmbientAwareness   60
  theme (store)      31 | ContradictionArcs  43
  ActivationNetwork  54 | PatternTransfer    31
  ImportanceRadar    35 | + existing 361 tests still green

**Gates passed**
- `npm run check`:  0 errors, 0 warnings across 623 files
- `npm test`:       910/910 passing, 22 test files
- `npm run build`:  clean adapter-static output

**Layout wiring**
- Nav array expanded 8 → 16 entries (existing 8 + 8 new routes)
- Single-key shortcuts added: R/A/D/C/P/U/X/N (no conflicts with
  existing G/M/T/F/E/I/S/,)
- Cmd+K palette search works across all 16
- Mobile nav = top 5 (Graph, Reasoning, Memories, Timeline, Feed)
- AmbientAwarenessStrip mounted as first child of <main>
- ThemeToggle rendered in sidebar footer (was imported-but-unmounted)
- Theme initTheme() + teardown wired into onMount cleanup chain

Net branch delta: 47 files changed, +13,756 insertions, -6 deletions
This commit is contained in:
Sam Valladares 2026-04-21 02:25:07 -05:00
parent 7441b3cdfe
commit 50869e96ff
47 changed files with 13756 additions and 6 deletions

View file

@ -0,0 +1,372 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import {
activationColor,
applyDecay,
edgeStagger,
initialActivation,
isVisible,
layoutNeighbours,
} from './activation-helpers';
/**
* ActivationNetwork — visualizes spreading activation (Collins & Loftus 1975)
* across the cognitive memory graph.
*
* Every burst places a source node at the center with activated neighbours
* on concentric rings. Edges draw in with a staggered delay to visualize the
* activation wavefront; ripple waves expand outward from the source on each
* burst; activation level decays every animation frame by 0.93 until it
* drops below 0.05, at which point the node fades out.
*
* The component supports multiple overlapping bursts — each call to
* `trigger(sourceId, sourceLabel, neighbours)` merges into the current
* activation state rather than replacing it, so live mode feels like a
* continuous neural storm instead of a reset.
*/
export interface ActivationNode {
id: string;
label: string;
nodeType: string;
// Neighbours only — omit on source
score?: number;
}
interface Props {
width?: number;
height?: number;
/** Current focused burst; null when idle */
source?: ActivationNode | null;
/** Neighbours of the current focused burst (drawn immediately on mount) */
neighbours?: ActivationNode[];
/** Bursts triggered via live mode — each one overlays on the graph */
liveBurstKey?: number;
liveBurst?: { source: ActivationNode; neighbours: ActivationNode[] } | null;
}
let {
width = 900,
height = 560,
source = null,
neighbours = [],
liveBurstKey = 0,
liveBurst = null,
}: Props = $props();
// Decay/geometry constants live in `./activation-helpers` so the pure-
// function test suite can exercise them without rendering Svelte. These
// two visual-only constants stay local because they're tied to the SVG
// node drawing below.
const SOURCE_RADIUS = 22;
const NEIGHBOUR_RADIUS_BASE = 14;
interface ActiveNode {
id: string;
label: string;
nodeType: string;
x: number;
y: number;
activation: number; // 0..1 — drives size and opacity
isSource: boolean;
sourceBurstId: number; // which burst does this node belong to
}
interface ActiveEdge {
burstId: number;
sourceNodeId: string;
targetNodeId: string;
// Each edge has its own draw progress so we can stagger them
drawProgress: number; // 0..1
staggerDelay: number; // frames to wait before drawing
framesElapsed: number;
}
interface Ripple {
burstId: number;
x: number;
y: number;
radius: number;
opacity: number;
}
let activeNodes = $state<ActiveNode[]>([]);
let activeEdges = $state<ActiveEdge[]>([]);
let ripples = $state<Ripple[]>([]);
let burstCounter = 0;
let animationFrame: number | null = null;
let lastPropSource: string | null = null;
let lastLiveKey = 0;
function triggerBurst(
src: ActivationNode,
nbrs: ActivationNode[],
centerX: number,
centerY: number
) {
burstCounter += 1;
const burstId = burstCounter;
// If a live burst hits the same source that is already at center, offset
// it slightly so the visual distinction is preserved without chaos.
const jitter = liveBurstKey > 0 && activeNodes.length > 0 ? 40 : 0;
const cx = centerX + (Math.random() - 0.5) * jitter;
const cy = centerY + (Math.random() - 0.5) * jitter;
// Ripple wave from this source
ripples = [
...ripples,
{ burstId, x: cx, y: cy, radius: SOURCE_RADIUS, opacity: 0.75 },
{ burstId, x: cx, y: cy, radius: SOURCE_RADIUS, opacity: 0.5 },
];
// Source node — full activation
const sourceNode: ActiveNode = {
id: `${src.id}::${burstId}`,
label: src.label,
nodeType: 'source',
x: cx,
y: cy,
activation: 1,
isSource: true,
sourceBurstId: burstId,
};
const neighbourNodes: ActiveNode[] = [];
const newEdges: ActiveEdge[] = [];
// Slight rotation per burst so overlapping bursts don't fully collide
const angleOffset = (burstCounter * 0.37) % (Math.PI * 2);
const allPositions = layoutNeighbours(cx, cy, nbrs.length, angleOffset);
nbrs.forEach((nbr, i) => {
const pos = allPositions[i];
if (!pos) return;
neighbourNodes.push({
id: `${nbr.id}::${burstId}`,
label: nbr.label,
nodeType: nbr.nodeType,
x: pos.x,
y: pos.y,
activation: initialActivation(i, nbrs.length),
isSource: false,
sourceBurstId: burstId,
});
newEdges.push({
burstId,
sourceNodeId: sourceNode.id,
targetNodeId: `${nbr.id}::${burstId}`,
drawProgress: 0,
staggerDelay: edgeStagger(i),
framesElapsed: 0,
});
});
activeNodes = [...activeNodes, sourceNode, ...neighbourNodes];
activeEdges = [...activeEdges, ...newEdges];
}
function tick() {
// Decay node activations (Collins & Loftus 1975, 0.93/frame).
let nextNodes: ActiveNode[] = [];
for (const n of activeNodes) {
const nextActivation = applyDecay(n.activation);
if (!isVisible(nextActivation)) continue;
nextNodes.push({ ...n, activation: nextActivation });
}
activeNodes = nextNodes;
// Advance edge draw progress (only for edges whose endpoints still exist)
const liveIds = new Set(nextNodes.map((n) => n.id));
let nextEdges: ActiveEdge[] = [];
for (const e of activeEdges) {
if (!liveIds.has(e.sourceNodeId) || !liveIds.has(e.targetNodeId)) continue;
const elapsed = e.framesElapsed + 1;
let progress = e.drawProgress;
if (elapsed >= e.staggerDelay) {
// 0..1 over ~15 frames (~0.25s at 60fps)
progress = Math.min(1, progress + 1 / 15);
}
nextEdges.push({ ...e, framesElapsed: elapsed, drawProgress: progress });
}
activeEdges = nextEdges;
// Expand ripples outward, fade opacity
let nextRipples: Ripple[] = [];
for (const r of ripples) {
const nextRadius = r.radius + 6;
const nextOpacity = r.opacity * 0.96;
if (nextOpacity < 0.02 || nextRadius > Math.max(width, height)) continue;
nextRipples.push({ ...r, radius: nextRadius, opacity: nextOpacity });
}
ripples = nextRipples;
animationFrame = requestAnimationFrame(tick);
}
function clearBursts() {
activeNodes = [];
activeEdges = [];
ripples = [];
}
// Watch for prop-driven bursts (initial search result)
$effect(() => {
if (!source) return;
const sourceKey = source.id;
if (sourceKey === lastPropSource) return;
lastPropSource = sourceKey;
clearBursts();
triggerBurst(source, neighbours, width / 2, height / 2);
});
// Watch for live bursts — each keyed trigger overlays a new burst at a
// random-ish location near center so they don't stack directly on top.
$effect(() => {
if (!liveBurst || liveBurstKey === 0) return;
if (liveBurstKey === lastLiveKey) return;
lastLiveKey = liveBurstKey;
// Live bursts land near but not exactly on center so they're visually
// distinct from the primary burst.
const offsetX = (Math.random() - 0.5) * 120;
const offsetY = (Math.random() - 0.5) * 120;
triggerBurst(liveBurst.source, liveBurst.neighbours, width / 2 + offsetX, height / 2 + offsetY);
});
onMount(() => {
animationFrame = requestAnimationFrame(tick);
});
onDestroy(() => {
if (animationFrame !== null) cancelAnimationFrame(animationFrame);
});
function nodeColor(nodeType: string, isSource: boolean): string {
return activationColor(nodeType, isSource);
}
function edgePoint(edge: ActiveEdge): {
x1: number;
y1: number;
x2: number;
y2: number;
} | null {
const src = activeNodes.find((n) => n.id === edge.sourceNodeId);
const tgt = activeNodes.find((n) => n.id === edge.targetNodeId);
if (!src || !tgt) return null;
// Clip to current draw progress so the edge grows outward from source
const x2 = src.x + (tgt.x - src.x) * edge.drawProgress;
const y2 = src.y + (tgt.y - src.y) * edge.drawProgress;
return { x1: src.x, y1: src.y, x2, y2 };
}
</script>
<svg
{width}
{height}
viewBox="0 0 {width} {height}"
class="w-full h-full block"
aria-label="Spreading activation visualization"
>
<defs>
<filter id="act-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="4" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="act-glow-strong" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="8" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<radialGradient id="ripple-grad" cx="50%" cy="50%" r="50%">
<stop offset="70%" stop-color="#818cf8" stop-opacity="0" />
<stop offset="100%" stop-color="#818cf8" stop-opacity="0.7" />
</radialGradient>
</defs>
<!-- Ripple wavefronts (expanding circles from source) -->
{#each ripples as r, i (i)}
<circle
cx={r.x}
cy={r.y}
r={r.radius}
fill="none"
stroke="#818cf8"
stroke-width="1.5"
opacity={r.opacity}
/>
{/each}
<!-- Edges (drawn with stagger delay so activation appears to spread) -->
{#each activeEdges as e, i (i)}
{@const pt = edgePoint(e)}
{#if pt}
<line
x1={pt.x1}
y1={pt.y1}
x2={pt.x2}
y2={pt.y2}
stroke="#818cf8"
stroke-width="1.2"
stroke-linecap="round"
opacity={0.35 * e.drawProgress}
/>
{/if}
{/each}
<!-- Nodes -->
{#each activeNodes as n (n.id)}
{@const color = nodeColor(n.nodeType, n.isSource)}
{@const r = n.isSource
? SOURCE_RADIUS * (0.7 + 0.3 * n.activation)
: NEIGHBOUR_RADIUS_BASE * (0.5 + 0.8 * n.activation)}
<g opacity={Math.min(1, n.activation * 1.25)}>
<!-- Soft outer glow halo -->
<circle
cx={n.x}
cy={n.y}
r={r * 1.9}
fill={color}
opacity={0.18 * n.activation}
filter="url(#act-glow-strong)"
/>
<!-- Core -->
<circle
cx={n.x}
cy={n.y}
r={r}
fill={color}
filter="url(#act-glow)"
/>
<!-- Inner highlight for depth -->
<circle
cx={n.x - r * 0.3}
cy={n.y - r * 0.3}
r={r * 0.35}
fill="#ffffff"
opacity={0.35 * n.activation}
/>
{#if n.isSource && n.label}
<text
x={n.x}
y={n.y + r + 18}
text-anchor="middle"
fill="#e0e0ff"
font-size="11"
font-family="var(--font-mono)"
opacity={0.9 * n.activation}
>
{n.label.length > 40 ? n.label.slice(0, 40) + '…' : n.label}
</text>
{/if}
</g>
{/each}
</svg>

View file

@ -0,0 +1,312 @@
<!--
AmbientAwarenessStrip — persistent slim top-of-viewport band surfacing
live cognitive engine vitals without demanding attention.
Contents (left → right):
1. Retention Vitals — pulsing dot + "N memories · X% avg retention"
2. At-Risk Count — memories with retention < 0.3 (or "" if unknown)
3. Active Intentions — count of active intentions, pings pink if >5
4. Recent Dream — last DreamCompleted within 24h summary
5. Activity Pulse — 10-bar sparkline of events/min over last 5 min
6. Now Dreaming? — violet pulsing dot while a Dream is in flight
7. Sanhedrin Watch — subtle red flash on MemorySuppressed in last 10s
Design: full-width band, dark-glass backdrop, border-bottom synapse/15,
height ≈36px, dim muted text with colored accents ONLY on pulsing/urgent
items. Not clickable — ambient info only.
Mobile: collapses to items 1, 2, 6 to save width.
-->
<script lang="ts">
import { onMount } from 'svelte';
import {
memoryCount,
avgRetention,
eventFeed,
} from '$stores/websocket';
import { api } from '$stores/api';
import {
bucketizeActivity,
dreamInsightsCount,
findRecentDream,
formatAgo,
hasRecentSuppression,
isDreaming as isDreamingFn,
parseEventTimestamp,
} from './awareness-helpers';
// ─────────────────────────────────────────────────────────────────────────
// 1. Retention vitals — derived straight from heartbeat stores
// ─────────────────────────────────────────────────────────────────────────
const retentionPct = $derived(Math.round(($avgRetention ?? 0) * 100));
const retentionHealthy = $derived(($avgRetention ?? 0) >= 0.5);
// ─────────────────────────────────────────────────────────────────────────
// 2. At-risk count — fetched once from /retention-distribution.
// Sum buckets whose range label implies retention < 0.3 ("0-20%" and
// "20-40%"). Robust to absent/unknown backend: stays `null` → shows "—".
// ─────────────────────────────────────────────────────────────────────────
let atRiskCount = $state<number | null>(null);
async function loadAtRisk(): Promise<void> {
try {
const dist = await api.retentionDistribution();
// Prefer direct `endangered` list if backend populates it.
if (Array.isArray(dist.endangered) && dist.endangered.length > 0) {
atRiskCount = dist.endangered.length;
return;
}
// Otherwise sum buckets whose lower bound < 30%.
const buckets = dist.distribution ?? [];
let total = 0;
for (const b of buckets) {
const m = /^(\d+)/.exec(b.range);
if (!m) continue;
const low = Number.parseInt(m[1], 10);
if (Number.isFinite(low) && low < 30) total += b.count ?? 0;
}
atRiskCount = total;
} catch {
atRiskCount = null;
}
}
// ─────────────────────────────────────────────────────────────────────────
// 3. Active intentions — fetched once from /intentions?status=active
// ─────────────────────────────────────────────────────────────────────────
let intentionsCount = $state<number | null>(null);
async function loadIntentions(): Promise<void> {
try {
const res = await api.intentions('active');
intentionsCount = res.total ?? res.intentions?.length ?? 0;
} catch {
intentionsCount = null;
}
}
// ─────────────────────────────────────────────────────────────────────────
// 4 & 6. Dream awareness — pure helpers scan $eventFeed. Newest-first.
// ─────────────────────────────────────────────────────────────────────────
let nowTick = $state(Date.now());
const dreamState = $derived.by(() => {
const feed = $eventFeed;
const recent = findRecentDream(feed, nowTick);
const recentAt = recent ? parseEventTimestamp(recent) ?? nowTick : null;
const recentMsAgo = recentAt !== null ? nowTick - recentAt : null;
return {
isDreaming: isDreamingFn(feed, nowTick),
recent,
recentMsAgo,
insights: dreamInsightsCount(recent),
};
});
// ─────────────────────────────────────────────────────────────────────────
// 5. Activity pulse — bucket $eventFeed timestamps into 10 × 30s buckets
// over the last 5 minutes. Bucket 0 = oldest, 9 = newest.
// ─────────────────────────────────────────────────────────────────────────
const sparkline = $derived(bucketizeActivity($eventFeed, nowTick));
// ─────────────────────────────────────────────────────────────────────────
// 7. Sanhedrin watch — flash red on any MemorySuppressed in last 10s
// ─────────────────────────────────────────────────────────────────────────
const suppressionFlash = $derived(hasRecentSuppression($eventFeed, nowTick));
// ─────────────────────────────────────────────────────────────────────────
// Ticker — advance `nowTick` every second so time-based derived values
// (dreaming window, activity window, suppression flash) refresh smoothly.
// ─────────────────────────────────────────────────────────────────────────
onMount(() => {
void loadAtRisk();
void loadIntentions();
const tickHandle = setInterval(() => {
nowTick = Date.now();
}, 1000);
// Refresh the slow API-backed counts every 60s so they don't go stale.
const slowHandle = setInterval(() => {
void loadAtRisk();
void loadIntentions();
}, 60_000);
return () => {
clearInterval(tickHandle);
clearInterval(slowHandle);
};
});
</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-flash={suppressionFlash}
aria-label="Ambient cognitive vitals"
>
<!-- 1. Retention vitals — always visible -->
<div class="strip-item" title="Total memories and average retention strength">
<span class="relative inline-flex h-2 w-2 items-center justify-center">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"
class:bg-recall={retentionHealthy}
class:bg-warning={!retentionHealthy}
></span>
<span
class="relative inline-flex h-2 w-2 rounded-full"
class:bg-recall={retentionHealthy}
class:bg-warning={!retentionHealthy}
></span>
</span>
<span class="text-text/80 tabular-nums">{$memoryCount}</span>
<span class="text-muted">memories</span>
<span class="text-muted/60">·</span>
<span class:text-recall={retentionHealthy} class:text-warning={!retentionHealthy}>
{retentionPct}%
</span>
<span class="text-muted">avg retention</span>
</div>
<div class="strip-divider" aria-hidden="true"></div>
<!-- 2. At-risk — always visible -->
<div class="strip-item" title="Memories with retention below 30%">
{#if atRiskCount !== null && atRiskCount > 0}
<span class="font-semibold tabular-nums text-decay">{atRiskCount}</span>
<span class="text-muted">at risk</span>
{:else if atRiskCount === 0}
<span class="text-muted tabular-nums">0</span>
<span class="text-muted">at risk</span>
{:else}
<span class="text-muted/60"></span>
<span class="text-muted">at risk</span>
{/if}
</div>
<!-- 3. Active intentions — hidden on mobile -->
<div class="strip-divider hidden md:block" aria-hidden="true"></div>
<div class="strip-item hidden md:inline-flex" title="Active intentions (prospective memory)">
{#if intentionsCount !== null}
<span
class="inline-flex h-2 w-2 rounded-full"
class:bg-node-pattern={intentionsCount > 5}
class:animate-ping-slow={intentionsCount > 5}
class:bg-muted={intentionsCount <= 5}
></span>
<span
class="tabular-nums"
class:text-node-pattern={intentionsCount > 5}
class:text-text={intentionsCount > 0 && intentionsCount <= 5}
class:text-muted={intentionsCount === 0}
>
{intentionsCount}
</span>
<span class="text-muted">intentions</span>
{:else}
<span class="text-muted/60">— intentions</span>
{/if}
</div>
<!-- 4. Recent dream — hidden on mobile -->
<div class="strip-divider hidden md:block" aria-hidden="true"></div>
<div class="strip-item hidden md:inline-flex" title="Most recent Dream cycle completion">
{#if dreamState.recent && dreamState.recentMsAgo !== null}
<span class="text-dream/80"></span>
<span class="text-muted">Last dream:</span>
<span class="text-text/80">{formatAgo(dreamState.recentMsAgo)}</span>
{#if dreamState.insights !== null}
<span class="text-muted/60">·</span>
<span class="text-text/80 tabular-nums">{dreamState.insights}</span>
<span class="text-muted">insights</span>
{/if}
{:else}
<span class="text-muted">No recent dream</span>
{/if}
</div>
<!-- 5. Activity pulse sparkline — hidden on mobile -->
<div class="strip-divider hidden md:block" aria-hidden="true"></div>
<div
class="strip-item hidden md:inline-flex"
title="Event throughput over the last 5 minutes (events per 30s)"
>
<span class="text-muted">activity</span>
<div class="flex h-4 items-end gap-[2px]" aria-hidden="true">
{#each sparkline as bar}
<div
class="w-[3px] rounded-sm bg-synapse/70"
style="height: {Math.max(10, bar.ratio * 100)}%; opacity: {bar.count === 0 ? 0.18 : 0.5 + bar.ratio * 0.5};"
></div>
{/each}
</div>
</div>
<!-- 6. Now dreaming? — always visible when active -->
{#if dreamState.isDreaming}
<div class="strip-divider" aria-hidden="true"></div>
<div class="strip-item" title="A Dream cycle is currently in progress">
<span class="relative inline-flex h-2 w-2 items-center justify-center">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-dream opacity-75"
></span>
<span class="relative inline-flex h-2 w-2 rounded-full bg-dream"></span>
</span>
<span class="font-semibold tracking-wider text-dream-glow">DREAMING...</span>
</div>
{/if}
<!-- Spacer -->
<div class="flex-1"></div>
<!-- 7. Sanhedrin watch — subtle right-aligned flash, hidden on mobile -->
{#if suppressionFlash}
<div class="strip-item hidden md:inline-flex" title="A memory was just suppressed (Sanhedrin veto)">
<span
class="inline-flex h-2 w-2 animate-pulse rounded-full bg-decay shadow-[0_0_10px_rgba(239,68,68,0.7)]"
></span>
<span class="font-medium text-decay">Veto triggered</span>
</div>
{/if}
</div>
<style>
.strip-item {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0 0.75rem;
white-space: nowrap;
flex-shrink: 0;
}
.strip-divider {
width: 1px;
height: 14px;
background: rgba(99, 102, 241, 0.12);
flex-shrink: 0;
}
/* Subtle red wash when a suppression just fired. */
.ambient-strip.ambient-flash {
background:
linear-gradient(90deg, rgba(239, 68, 68, 0.08), rgba(239, 68, 68, 0) 70%),
rgba(0, 0, 0, 0.4);
border-bottom-color: rgba(239, 68, 68, 0.35);
transition: background 0.3s ease, border-color 0.3s ease;
}
/* Slower "ping" for the intentions pink dot — less aggressive than the
default Tailwind animate-ping. */
@keyframes ping-slow {
0% { transform: scale(1); opacity: 0.8; }
80%, 100% { transform: scale(2); opacity: 0; }
}
:global(.animate-ping-slow) {
animation: ping-slow 2.2s cubic-bezier(0, 0, 0.2, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.ambient-strip :global(.animate-ping),
.ambient-strip :global(.animate-ping-slow),
.ambient-strip :global(.animate-pulse) {
animation: none !important;
}
}
</style>

View file

@ -0,0 +1,421 @@
<script lang="ts">
/**
* ContradictionArcs — 2D cosmic constellation of conflicting memories.
*
* Renders each contradiction pair as two nodes connected by an arc.
* Arc color = similarity severity (red/amber/yellow).
* Arc thickness = min(trust_a, trust_b) — stake.
* Node size = trust score. Node hue = node_type (bioluminescent).
*
* SVG-only (no Three.js) to keep it lightweight and print-friendly.
*/
import {
nodeColor,
severityColor,
severityLabel,
nodeRadius,
pairOpacity,
truncate,
} from './contradiction-helpers';
export interface Contradiction {
memory_a_id: string;
memory_b_id: string;
memory_a_preview: string;
memory_b_preview: string;
memory_a_type?: string;
memory_b_type?: string;
memory_a_created?: string;
memory_b_created?: string;
memory_a_tags?: string[];
memory_b_tags?: string[];
trust_a: number; // 0..1
trust_b: number; // 0..1
similarity: number; // 0..1
date_diff_days: number;
topic: string;
}
interface Props {
contradictions: Contradiction[];
focusedPairIndex?: number | null;
onSelectPair?: (index: number | null) => void;
width?: number;
height?: number;
}
let {
contradictions,
focusedPairIndex = null,
onSelectPair,
width = 800,
height = 600
}: Props = $props();
// --- Polar layout: place pairs around a circle, with the arc crossing the interior. ---
// Each pair is given a slot on the circumference; node A and node B are placed
// symmetrically around that slot at a small angular offset proportional to similarity
// (more similar = farther apart visually, so the tension is readable).
// Wrapped in $derived so Svelte 5 re-computes when $state `width`/`height` change,
// instead of capturing their initial values once.
const geom = $derived.by(() => {
const cx = width / 2;
const cy = height / 2;
const R = Math.min(width, height) * 0.38;
return { cx, cy, R };
});
interface NodePoint {
x: number;
y: number;
trust: number;
preview: string;
type?: string;
created?: string;
tags?: string[];
memoryId: string;
pairIndex: number;
side: 'a' | 'b';
}
interface ArcShape {
pairIndex: number;
path: string;
color: string;
thickness: number;
severity: string;
topic: string;
similarity: number;
dateDiff: number;
aPoint: NodePoint;
bPoint: NodePoint;
// Midpoint used for particle animation origin
midX: number;
midY: number;
}
const layout = $derived.by((): { nodes: NodePoint[]; arcs: ArcShape[] } => {
const nodes: NodePoint[] = [];
const arcs: ArcShape[] = [];
const n = contradictions.length || 1;
contradictions.forEach((c, i) => {
const slot = (i / n) * Math.PI * 2 - Math.PI / 2;
// Angular offset between A and B within the slot — proportional to sim
const spread = 0.18 + c.similarity * 0.22;
const angA = slot - spread;
const angB = slot + spread;
// Slight radial jitter so the constellation doesn't look like a perfect ring
const rA = geom.R + (Math.sin(i * 2.3) * 18);
const rB = geom.R + (Math.cos(i * 1.7) * 18);
const aPoint: NodePoint = {
x: geom.cx + Math.cos(angA) * rA,
y: geom.cy + Math.sin(angA) * rA,
trust: c.trust_a,
preview: c.memory_a_preview,
type: c.memory_a_type,
created: c.memory_a_created,
tags: c.memory_a_tags,
memoryId: c.memory_a_id,
pairIndex: i,
side: 'a'
};
const bPoint: NodePoint = {
x: geom.cx + Math.cos(angB) * rB,
y: geom.cy + Math.sin(angB) * rB,
trust: c.trust_b,
preview: c.memory_b_preview,
type: c.memory_b_type,
created: c.memory_b_created,
tags: c.memory_b_tags,
memoryId: c.memory_b_id,
pairIndex: i,
side: 'b'
};
nodes.push(aPoint, bPoint);
// Arc bends toward the centre — cosmic bridge.
// Control point = midpoint pulled toward centre by (1 - similarity * 0.3)
const mx = (aPoint.x + bPoint.x) / 2;
const my = (aPoint.y + bPoint.y) / 2;
const pullStrength = 0.55 - c.similarity * 0.25;
const ctrlX = mx + (geom.cx - mx) * pullStrength;
const ctrlY = my + (geom.cy - my) * pullStrength;
const thickness = 1 + Math.min(c.trust_a, c.trust_b) * 4;
arcs.push({
pairIndex: i,
path: `M ${aPoint.x.toFixed(1)} ${aPoint.y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${bPoint.x.toFixed(1)} ${bPoint.y.toFixed(1)}`,
color: severityColor(c.similarity),
thickness,
severity: severityLabel(c.similarity),
topic: c.topic,
similarity: c.similarity,
dateDiff: c.date_diff_days,
aPoint,
bPoint,
midX: ctrlX,
midY: ctrlY
});
});
return { nodes, arcs };
});
// --- Hover tooltip state ---
let hoverNode = $state<NodePoint | null>(null);
let hoverArc = $state<ArcShape | null>(null);
let mouseX = $state(0);
let mouseY = $state(0);
function onMove(e: MouseEvent) {
const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect();
mouseX = e.clientX - rect.left;
mouseY = e.clientY - rect.top;
}
function handleArcClick(i: number) {
if (!onSelectPair) return;
onSelectPair(focusedPairIndex === i ? null : i);
}
function handleBgClick() {
onSelectPair?.(null);
}
</script>
<div class="relative w-full" style="aspect-ratio: {width} / {height};">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<svg
{width}
{height}
viewBox="0 0 {width} {height}"
class="w-full h-full"
role="img"
aria-label="Contradiction constellation map — click an arc to focus, click background to deselect"
onmousemove={onMove}
onmouseleave={() => { hoverNode = null; hoverArc = null; }}
onclick={handleBgClick}
>
<defs>
<!-- Radial glass background -->
<radialGradient id="bgGrad" cx="50%" cy="50%" r="65%">
<stop offset="0%" stop-color="#10102a" stop-opacity="0.9" />
<stop offset="60%" stop-color="#0a0a1a" stop-opacity="0.7" />
<stop offset="100%" stop-color="#050510" stop-opacity="0.4" />
</radialGradient>
<!-- Soft arc gradients per severity, for a glow feel -->
<linearGradient id="arcGradRed" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ef4444" stop-opacity="0.1" />
<stop offset="50%" stop-color="#ef4444" stop-opacity="1" />
<stop offset="100%" stop-color="#ef4444" stop-opacity="0.1" />
</linearGradient>
<linearGradient id="arcGradAmber" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#f59e0b" stop-opacity="0.1" />
<stop offset="50%" stop-color="#f59e0b" stop-opacity="1" />
<stop offset="100%" stop-color="#f59e0b" stop-opacity="0.1" />
</linearGradient>
<linearGradient id="arcGradYellow" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#fde047" stop-opacity="0.1" />
<stop offset="50%" stop-color="#fde047" stop-opacity="1" />
<stop offset="100%" stop-color="#fde047" stop-opacity="0.1" />
</linearGradient>
<!-- Node glow filter -->
<filter id="nodeGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2.5" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="arcGlow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<!-- Cosmic background -->
<rect x="0" y="0" {width} {height} fill="url(#bgGrad)" rx="16" />
<!-- Subtle reference ring so the layout reads as a constellation -->
<circle
cx={geom.cx}
cy={geom.cy}
r={geom.R}
fill="none"
stroke="#6366f1"
stroke-opacity="0.06"
stroke-dasharray="2 6"
/>
<circle cx={geom.cx} cy={geom.cy} r="3" fill="#6366f1" opacity="0.4" />
<!-- Arcs (cosmic bridges) — rendered before nodes so nodes sit on top -->
{#each layout.arcs as arc (arc.pairIndex)}
{@const op = pairOpacity(arc.pairIndex, focusedPairIndex)}
{@const isFocused = focusedPairIndex === arc.pairIndex}
<!-- Outer halo -->
<path
d={arc.path}
fill="none"
stroke={arc.color}
stroke-width={arc.thickness * 3}
stroke-opacity={0.08 * op}
stroke-linecap="round"
filter="url(#arcGlow)"
pointer-events="none"
/>
<!-- Primary arc -->
<path
d={arc.path}
fill="none"
stroke={arc.color}
stroke-width={arc.thickness * (isFocused ? 1.6 : 1)}
stroke-opacity={(isFocused ? 1 : 0.72) * op}
stroke-linecap="round"
class="cursor-pointer transition-all duration-200"
onclick={(e) => { e.stopPropagation(); handleArcClick(arc.pairIndex); }}
onmouseenter={() => (hoverArc = arc)}
onmouseleave={() => (hoverArc = null)}
aria-label="contradiction {arc.pairIndex + 1}: {arc.topic}"
role="button"
tabindex="0"
onkeydown={(e) => { if (e.key === 'Enter') handleArcClick(arc.pairIndex); }}
/>
<!-- Particle: a small dashed overlay that drifts along the arc to show tension flow -->
<path
d={arc.path}
fill="none"
stroke={arc.color}
stroke-width={Math.max(1, arc.thickness * 0.6)}
stroke-opacity={0.85 * op}
stroke-linecap="round"
stroke-dasharray="2 14"
class="arc-particle"
style="animation-duration: {4 + (arc.pairIndex % 5)}s"
pointer-events="none"
/>
{/each}
<!-- Nodes -->
{#each layout.nodes as node, i (node.memoryId + '-' + node.side + '-' + i)}
{@const op = pairOpacity(node.pairIndex, focusedPairIndex)}
{@const isFocused = focusedPairIndex === node.pairIndex}
{@const r = nodeRadius(node.trust)}
{@const fill = nodeColor(node.type)}
<!-- Outer glow -->
<circle
cx={node.x}
cy={node.y}
r={r * 2.2}
fill={fill}
opacity={0.12 * op}
filter="url(#nodeGlow)"
pointer-events="none"
/>
<!-- Core -->
<circle
cx={node.x}
cy={node.y}
r={r}
fill={fill}
opacity={op}
stroke="#ffffff"
stroke-opacity={isFocused ? 0.85 : 0.25}
stroke-width={isFocused ? 2 : 1}
class="cursor-pointer transition-all duration-200"
onmouseenter={() => (hoverNode = node)}
onmouseleave={() => (hoverNode = null)}
onclick={(e) => { e.stopPropagation(); handleArcClick(node.pairIndex); }}
role="button"
tabindex="0"
aria-label="memory {truncate(node.preview, 40)}"
onkeydown={(e) => { if (e.key === 'Enter') handleArcClick(node.pairIndex); }}
/>
<!-- Label (truncated) — only shown for focused pair to avoid clutter -->
{#if isFocused}
<text
x={node.x}
y={node.y - r - 8}
fill="#e0e0ff"
font-size="10"
font-family="var(--font-mono, monospace)"
text-anchor="middle"
pointer-events="none"
>{truncate(node.preview, 40)}</text>
{/if}
{/each}
<!-- Legend top-left -->
<g transform="translate(16, 16)" pointer-events="none">
<rect x="0" y="0" width="170" height="66" rx="8"
fill="#0a0a1a" fill-opacity="0.6" stroke="#6366f1" stroke-opacity="0.12" />
<text x="10" y="16" fill="#7a7aaa" font-size="10" font-family="var(--font-mono, monospace)">SEVERITY</text>
<circle cx="16" cy="30" r="4" fill="#ef4444" />
<text x="26" y="33" fill="#e0e0ff" font-size="10" font-family="var(--font-mono, monospace)">strong (&gt;0.7)</text>
<circle cx="16" cy="44" r="4" fill="#f59e0b" />
<text x="26" y="47" fill="#e0e0ff" font-size="10" font-family="var(--font-mono, monospace)">moderate (0.5-0.7)</text>
<circle cx="16" cy="58" r="4" fill="#fde047" />
<text x="26" y="61" fill="#e0e0ff" font-size="10" font-family="var(--font-mono, monospace)">mild (0.3-0.5)</text>
</g>
</svg>
<!-- Hover tooltip (absolute, HTML for readability) -->
{#if hoverNode}
<div
class="pointer-events-none absolute z-10 glass-panel rounded-lg px-3 py-2 text-xs max-w-xs shadow-xl"
style="left: {Math.max(0, Math.min(mouseX + 12, width - 240))}px; top: {Math.max(0, Math.min(mouseY - 8, height - 120))}px;"
>
<div class="flex items-center gap-2 mb-1">
<div class="w-2 h-2 rounded-full" style="background: {nodeColor(hoverNode.type)}"></div>
<span class="text-bright font-semibold">{hoverNode.type ?? 'memory'}</span>
<span class="text-muted ml-auto">trust {(hoverNode.trust * 100).toFixed(0)}%</span>
</div>
<div class="text-text mb-1">{hoverNode.preview}</div>
{#if hoverNode.created}
<div class="text-muted text-[10px]">created {hoverNode.created}</div>
{/if}
{#if hoverNode.tags && hoverNode.tags.length > 0}
<div class="text-muted text-[10px] mt-1">
{hoverNode.tags.slice(0, 4).join(' · ')}
</div>
{/if}
</div>
{:else if hoverArc}
<div
class="pointer-events-none absolute z-10 glass-panel rounded-lg px-3 py-2 text-xs max-w-xs shadow-xl"
style="left: {Math.max(0, Math.min(mouseX + 12, width - 240))}px; top: {Math.max(0, Math.min(mouseY - 8, height - 120))}px;"
>
<div class="flex items-center gap-2 mb-1">
<div class="w-2 h-2 rounded-full" style="background: {hoverArc.color}"></div>
<span class="text-bright font-semibold">{hoverArc.severity} conflict</span>
</div>
<div class="text-dim">topic: <span class="text-text">{hoverArc.topic}</span></div>
<div class="text-muted text-[10px] mt-1">
similarity {(hoverArc.similarity * 100).toFixed(0)}% · {hoverArc.dateDiff}d apart
</div>
</div>
{/if}
</div>
<style>
@keyframes arc-drift {
0% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -32; }
}
:global(.arc-particle) {
animation-name: arc-drift;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
</style>

View file

@ -0,0 +1,211 @@
<!--
DreamInsightCard — single insight from a dream cycle.
High-novelty insights (>0.7) get a golden glow. Low-novelty (<0.3) muted.
Source memory IDs are clickable → navigate to /memories/[id].
-->
<script lang="ts">
import { base } from '$app/paths';
import type { DreamInsight } from '$types';
import {
clamp01,
noveltyBand,
formatConfidencePct,
firstSourceIds,
extraSourceCount,
sourceMemoryHref,
shortMemoryId,
} from './dream-helpers';
interface Props {
insight: DreamInsight;
index?: number;
}
let { insight, index = 0 }: Props = $props();
let novelty = $derived(clamp01(insight.noveltyScore));
let confidence = $derived(clamp01(insight.confidence));
let band = $derived(noveltyBand(insight.noveltyScore));
let isHighNovelty = $derived(band === 'high');
let isLowNovelty = $derived(band === 'low');
let firstSources = $derived(firstSourceIds(insight.sourceMemories, 2));
let extraCount = $derived(extraSourceCount(insight.sourceMemories, 2));
const TYPE_COLORS: Record<string, string> = {
connection: '#818cf8',
pattern: '#ec4899',
contradiction: '#ef4444',
synthesis: '#c084fc',
emergence: '#f59e0b',
cluster: '#06b6d4'
};
let typeColor = $derived(TYPE_COLORS[insight.type?.toLowerCase() ?? ''] ?? '#a855f7');
</script>
<article
class="insight-card glass-panel rounded-xl p-4 space-y-3"
class:high-novelty={isHighNovelty}
class:low-novelty={isLowNovelty}
style="--insight-color: {typeColor}; --enter-delay: {index * 60}ms"
>
<!-- Type badge + novelty halo -->
<div class="flex items-center justify-between gap-2">
<span
class="text-[10px] uppercase tracking-[0.12em] font-semibold px-2 py-0.5 rounded-full"
style="background: {typeColor}22; color: {typeColor}; border: 1px solid {typeColor}55"
>
{insight.type ?? 'insight'}
</span>
{#if isHighNovelty}
<span class="text-[10px] text-warning font-semibold flex items-center gap-1">
<span class="sparkle"></span> novel
</span>
{/if}
</div>
<!-- Insight text -->
<p class="text-sm text-bright font-semibold leading-snug">
{insight.insight}
</p>
<!-- Novelty bar -->
<div class="space-y-1">
<div class="flex items-center justify-between text-[10px] text-dim uppercase tracking-wider">
<span>Novelty</span>
<span class="tabular-nums text-text/80">{novelty.toFixed(2)}</span>
</div>
<div class="novelty-track">
<div
class="novelty-fill"
style="width: {novelty * 100}%; background: linear-gradient(90deg, {typeColor}, var(--color-dream-glow))"
></div>
</div>
</div>
<!-- Confidence -->
<div class="flex items-center justify-between text-[11px]">
<span class="text-dim">Confidence</span>
<span
class="tabular-nums font-semibold"
style="color: {confidence > 0.7 ? '#10b981' : confidence > 0.4 ? '#f59e0b' : '#ef4444'}"
>
{formatConfidencePct(confidence)}
</span>
</div>
<!-- Source memories -->
{#if firstSources.length > 0}
<div class="pt-2 border-t border-white/5 space-y-1.5">
<div class="text-[10px] text-dim uppercase tracking-wider">
Sources
{#if extraCount > 0}
<span class="text-muted">(+{extraCount})</span>
{/if}
</div>
<div class="flex flex-wrap gap-1.5">
{#each firstSources as id (id)}
<a
href={sourceMemoryHref(id, base)}
class="source-chip font-mono text-[10px] px-2 py-0.5 rounded"
title="Open memory {id}"
>
{shortMemoryId(id)}
</a>
{/each}
</div>
</div>
{/if}
</article>
<style>
.insight-card {
position: relative;
border: 1px solid color-mix(in srgb, var(--insight-color) 20%, transparent);
transition: transform 400ms cubic-bezier(0.34, 1.56, 0.64, 1),
border-color 220ms ease, box-shadow 220ms ease;
animation: card-in 420ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
animation-delay: var(--enter-delay, 0ms);
}
.insight-card:hover {
transform: translateY(-2px) scale(1.01);
border-color: color-mix(in srgb, var(--insight-color) 45%, transparent);
}
.insight-card.high-novelty {
border-color: rgba(245, 158, 11, 0.4);
box-shadow:
0 0 0 1px rgba(245, 158, 11, 0.25),
0 0 24px -4px rgba(245, 158, 11, 0.45),
0 0 60px -12px rgba(245, 158, 11, 0.25),
inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
background:
radial-gradient(at top right, rgba(245, 158, 11, 0.08), transparent 50%),
rgba(10, 10, 26, 0.8);
}
.insight-card.low-novelty {
opacity: 0.6;
filter: saturate(0.7);
}
.insight-card.low-novelty:hover {
opacity: 0.9;
filter: saturate(1);
}
.novelty-track {
height: 4px;
background: rgba(255, 255, 255, 0.05);
border-radius: 2px;
overflow: hidden;
}
.novelty-fill {
height: 100%;
border-radius: 2px;
transition: width 600ms cubic-bezier(0.34, 1.56, 0.64, 1);
box-shadow: 0 0 8px color-mix(in srgb, var(--insight-color) 60%, transparent);
}
.source-chip {
background: rgba(99, 102, 241, 0.12);
border: 1px solid rgba(99, 102, 241, 0.25);
color: var(--color-synapse-glow);
text-decoration: none;
transition: all 180ms ease;
}
.source-chip:hover {
background: rgba(99, 102, 241, 0.25);
border-color: rgba(129, 140, 248, 0.5);
transform: translateY(-1px);
}
.sparkle {
display: inline-block;
animation: sparkle-spin 3s linear infinite;
}
@keyframes sparkle-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes card-in {
from {
opacity: 0;
transform: translateY(8px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.insight-card { animation: none; }
.sparkle { animation: none; }
}
</style>

View file

@ -0,0 +1,539 @@
<!--
DreamStageReplay — Visual playback of a single dream-consolidation stage.
5 stages (per MemoryDreamer):
1. Replay — floating memory cards arrange themselves into a grid
2. Cross-reference — edges connect cards (SVG lines)
3. Strengthen — cards pulse, brighten, gain glow
4. Prune — low-retention cards fade & dissolve
5. Transfer — cards migrate from episodic (left) → semantic (right)
Pure CSS transforms + SVG + animations. No Three.js.
-->
<script lang="ts">
import type { DreamResult } from '$types';
import { clampStage } from './dream-helpers';
interface Props {
stage: number; // 1..5
dreamResult: DreamResult | null;
}
let { stage, dreamResult }: Props = $props();
const STAGES = [
{
num: 1,
name: 'Replay',
color: '#818cf8',
desc: 'Hippocampal replay: tagged memories surface for consolidation.'
},
{
num: 2,
name: 'Cross-reference',
color: '#a855f7',
desc: 'Semantic proximity check — new edges discovered across memories.'
},
{
num: 3,
name: 'Strengthen',
color: '#c084fc',
desc: 'Co-activated memories strengthen; FSRS stability grows.'
},
{
num: 4,
name: 'Prune',
color: '#ef4444',
desc: 'Low-retention redundant memories compressed or released.'
},
{
num: 5,
name: 'Transfer',
color: '#10b981',
desc: 'Episodic → semantic consolidation (hippocampus → cortex).'
}
];
// Lock stage to valid range (handles NaN / negatives / >5)
let stageIdx = $derived(clampStage(stage));
let current = $derived(STAGES[stageIdx - 1]);
// Derive card count from dream result (clamp 6..12 for visual density)
let cardCount = $derived.by(() => {
if (!dreamResult) return 8;
const n = dreamResult.memoriesReplayed ?? 8;
return Math.max(6, Math.min(12, n));
});
// Connection count from dream result
let connectionCount = $derived.by(() => {
if (!dreamResult) return 5;
const n = dreamResult.stats?.newConnectionsFound ?? 5;
return Math.max(3, Math.min(cardCount, n));
});
let strengthenedCount = $derived.by(() => {
if (!dreamResult) return Math.ceil(cardCount * 0.5);
const n = dreamResult.stats?.memoriesStrengthened ?? Math.ceil(cardCount * 0.5);
return Math.max(1, Math.min(cardCount, n));
});
let prunedCount = $derived.by(() => {
if (!dreamResult) return Math.ceil(cardCount * 0.25);
const n = dreamResult.stats?.memoriesCompressed ?? Math.ceil(cardCount * 0.25);
return Math.max(1, Math.min(Math.floor(cardCount / 2), n));
});
// Deterministic pseudo-random for card positions so stage changes don't re-randomize
function seed(i: number, salt = 0): number {
const x = Math.sin((i + 1) * 9301 + 49297 + salt * 233) * 233280;
return x - Math.floor(x);
}
// Layout: cards on a circle-ish grid in the center.
// In stage 5, we'll override X based on transfer side.
interface CardPos {
id: number;
x: number; // 0..100 percent
y: number; // 0..100 percent
pruned: boolean;
strengthened: boolean;
transferIsSemantic: boolean;
}
let cards = $derived.by<CardPos[]>(() => {
const arr: CardPos[] = [];
const cols = Math.ceil(Math.sqrt(cardCount));
const rows = Math.ceil(cardCount / cols);
for (let i = 0; i < cardCount; i++) {
const col = i % cols;
const row = Math.floor(i / cols);
const baseX = 20 + (col / Math.max(1, cols - 1)) * 60;
const baseY = 20 + (row / Math.max(1, rows - 1)) * 60;
// jitter
const jx = (seed(i, 1) - 0.5) * 8;
const jy = (seed(i, 2) - 0.5) * 8;
arr.push({
id: i,
x: baseX + jx,
y: baseY + jy,
pruned: i < prunedCount,
strengthened: i < strengthenedCount,
transferIsSemantic: i % 2 === 0
});
}
return arr;
});
// Edges for stage 2 (and persist for 3+). Pick random pairs from available cards.
interface Edge {
a: number;
b: number;
}
let edges = $derived.by<Edge[]>(() => {
const e: Edge[] = [];
const n = cards.length;
for (let k = 0; k < connectionCount; k++) {
const a = Math.floor(seed(k, 7) * n);
let b = Math.floor(seed(k, 11) * n);
if (b === a) b = (a + 1) % n;
e.push({ a, b });
}
return e;
});
// Card displayed position depending on stage
function cardX(card: CardPos): number {
if (stageIdx === 5) {
// Migrate: episodic (left 15..35%) → semantic (right 65..85%)
const target = card.transferIsSemantic ? 75 : 25;
return target + (seed(card.id, 5) - 0.5) * 12;
}
return card.x;
}
function cardY(card: CardPos): number {
if (stageIdx === 5) {
return 25 + seed(card.id, 6) * 50;
}
return card.y;
}
function cardOpacity(card: CardPos): number {
if (stageIdx === 4 && card.pruned) return 0;
if (stageIdx === 5 && card.pruned) return 0.15;
return 1;
}
function cardScale(card: CardPos): number {
if (stageIdx === 3 && card.strengthened) return 1.18;
if (stageIdx === 4 && card.pruned) return 0.6;
return 1;
}
</script>
<div class="replay-stage glass-panel rounded-2xl overflow-hidden relative">
<!-- Stage header -->
<header class="flex items-center justify-between px-5 py-3 border-b border-white/5 relative z-10">
<div class="flex items-center gap-3">
<div
class="stage-badge w-9 h-9 rounded-full flex items-center justify-center font-mono font-bold text-sm"
style="
background: color-mix(in srgb, {current.color} 20%, transparent);
color: {current.color};
border: 1.5px solid {current.color};
box-shadow: 0 0 16px color-mix(in srgb, {current.color} 40%, transparent);
"
>
{current.num}
</div>
<div>
<div class="text-sm font-semibold text-bright tracking-wide">{current.name}</div>
<div class="text-[11px] text-dim leading-snug max-w-md">{current.desc}</div>
</div>
</div>
<div class="text-[10px] text-dim uppercase tracking-[0.15em] hidden sm:block">
Stage {current.num} / 5
</div>
</header>
<!-- Stage canvas -->
<div
class="stage-canvas"
style="--stage-color: {current.color}"
aria-label="Dream stage {current.num}{current.name}"
>
<!-- Left/right labels for transfer stage -->
{#if stageIdx === 5}
<div class="transfer-label episodic">
<span class="label-tag">Episodic</span>
<span class="label-sub">hippocampus</span>
</div>
<div class="transfer-label semantic">
<span class="label-tag">Semantic</span>
<span class="label-sub">cortex</span>
</div>
<div class="divider-line"></div>
{/if}
<!-- SVG edges (visible from stage 2 onward) -->
<svg
class="edges-layer"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
>
{#each edges as edge, i (edge.a + '-' + edge.b + '-' + i)}
{@const a = cards[edge.a]}
{@const b = cards[edge.b]}
{#if a && b}
{@const x1 = cardX(a)}
{@const y1 = cardY(a)}
{@const x2 = cardX(b)}
{@const y2 = cardY(b)}
<line
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke={current.color}
stroke-width={stageIdx === 2 ? 0.25 : stageIdx === 3 ? 0.35 : 0.2}
stroke-opacity={stageIdx < 2 ? 0 : stageIdx === 4 ? 0.25 : stageIdx === 5 ? 0.15 : 0.6}
stroke-dasharray={stageIdx === 2 ? '1.2 0.8' : 'none'}
class="edge-line"
style="--edge-delay: {i * 80}ms"
/>
{/if}
{/each}
</svg>
<!-- Memory cards -->
{#each cards as card (card.id)}
<div
class="memory-card"
class:is-pulsing={stageIdx === 3 && card.strengthened}
class:is-pruning={stageIdx === 4 && card.pruned}
class:is-transferring={stageIdx === 5}
class:semantic-side={stageIdx === 5 && card.transferIsSemantic}
style="
left: {cardX(card)}%;
top: {cardY(card)}%;
opacity: {cardOpacity(card)};
--card-scale: {cardScale(card)};
--card-delay: {card.id * 40}ms;
--card-hue: {seed(card.id, 3) * 60 - 30}deg;
"
>
<div class="card-inner">
<div class="card-dot"></div>
<div class="card-bar"></div>
<div class="card-bar short"></div>
</div>
</div>
{/each}
<!-- Ambient pulse for stage 1 (replay) -->
{#if stageIdx === 1}
<div class="replay-pulse" aria-hidden="true"></div>
{/if}
</div>
<!-- Stage footer with stats -->
<footer class="flex flex-wrap gap-x-6 gap-y-1 px-5 py-3 border-t border-white/5 text-[11px] text-dim">
{#if stageIdx === 1}
<span>Replaying <b class="text-bright tabular-nums">{dreamResult?.memoriesReplayed ?? cardCount}</b> memories</span>
{:else if stageIdx === 2}
<span>New connections found: <b class="text-bright tabular-nums">{dreamResult?.stats?.newConnectionsFound ?? connectionCount}</b></span>
{:else if stageIdx === 3}
<span>Strengthened: <b class="text-bright tabular-nums">{dreamResult?.stats?.memoriesStrengthened ?? strengthenedCount}</b></span>
{:else if stageIdx === 4}
<span>Compressed: <b class="text-bright tabular-nums">{dreamResult?.stats?.memoriesCompressed ?? prunedCount}</b></span>
{:else if stageIdx === 5}
<span>Connections persisted: <b class="text-bright tabular-nums">{dreamResult?.connectionsPersisted ?? 0}</b></span>
<span>Insights: <b class="text-bright tabular-nums">{dreamResult?.stats?.insightsGenerated ?? 0}</b></span>
{/if}
</footer>
</div>
<style>
.replay-stage {
border: 1px solid rgba(168, 85, 247, 0.18);
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, 0.03),
0 8px 36px -8px rgba(0, 0, 0, 0.55),
0 0 48px -16px rgba(168, 85, 247, 0.25);
}
.stage-canvas {
position: relative;
height: 360px;
overflow: hidden;
background:
radial-gradient(at 50% 50%, color-mix(in srgb, var(--stage-color) 10%, transparent), transparent 60%),
radial-gradient(at 20% 80%, rgba(99, 102, 241, 0.08), transparent 50%),
#08081a;
}
.edges-layer {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.edge-line {
transition: stroke-opacity 520ms ease, stroke-width 520ms ease,
x1 600ms cubic-bezier(0.34, 1.56, 0.64, 1),
y1 600ms cubic-bezier(0.34, 1.56, 0.64, 1),
x2 600ms cubic-bezier(0.34, 1.56, 0.64, 1),
y2 600ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.memory-card {
position: absolute;
width: 44px;
height: 32px;
transform: translate(-50%, -50%) scale(var(--card-scale, 1));
transition:
left 600ms cubic-bezier(0.34, 1.56, 0.64, 1),
top 600ms cubic-bezier(0.34, 1.56, 0.64, 1),
transform 500ms cubic-bezier(0.34, 1.56, 0.64, 1),
opacity 500ms ease;
transition-delay: var(--card-delay, 0ms);
animation: card-float 6s ease-in-out infinite;
animation-delay: var(--card-delay, 0ms);
will-change: transform;
}
.card-inner {
width: 100%;
height: 100%;
border-radius: 6px;
background: linear-gradient(
135deg,
color-mix(in srgb, var(--stage-color) 30%, transparent),
color-mix(in srgb, var(--stage-color) 10%, transparent)
);
border: 1px solid color-mix(in srgb, var(--stage-color) 50%, transparent);
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, 0.08),
0 0 8px color-mix(in srgb, var(--stage-color) 30%, transparent);
filter: hue-rotate(var(--card-hue, 0deg));
padding: 5px 6px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 3px;
position: relative;
overflow: hidden;
}
.card-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: color-mix(in srgb, var(--stage-color) 90%, white);
box-shadow: 0 0 6px var(--stage-color);
position: absolute;
top: 4px;
right: 4px;
}
.card-bar {
height: 3px;
border-radius: 1.5px;
background: color-mix(in srgb, var(--stage-color) 70%, transparent);
width: 80%;
}
.card-bar.short {
width: 50%;
opacity: 0.6;
}
.memory-card.is-pulsing {
animation: card-float 6s ease-in-out infinite, card-pulse 1.4s ease-in-out infinite;
}
.memory-card.is-pulsing .card-inner {
border-color: var(--color-dream-glow);
background: linear-gradient(
135deg,
color-mix(in srgb, var(--color-dream-glow) 40%, transparent),
color-mix(in srgb, var(--color-dream) 25%, transparent)
);
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, 0.12),
0 0 22px color-mix(in srgb, var(--color-dream-glow) 60%, transparent),
0 0 44px color-mix(in srgb, var(--color-dream) 35%, transparent);
}
.memory-card.is-pruning .card-inner {
animation: dissolve 1.2s ease-out forwards;
}
.memory-card.is-transferring .card-inner {
border-color: #10b981;
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.35),
rgba(16, 185, 129, 0.12)
);
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, 0.08),
0 0 14px rgba(16, 185, 129, 0.5);
}
.memory-card.is-transferring.semantic-side .card-inner {
border-color: #c084fc;
background: linear-gradient(
135deg,
rgba(192, 132, 252, 0.35),
rgba(168, 85, 247, 0.15)
);
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, 0.08),
0 0 14px rgba(192, 132, 252, 0.5);
}
.replay-pulse {
position: absolute;
top: 50%;
left: 50%;
width: 60%;
aspect-ratio: 1 / 1;
transform: translate(-50%, -50%);
border-radius: 50%;
background: radial-gradient(circle, color-mix(in srgb, var(--stage-color) 25%, transparent), transparent 60%);
filter: blur(30px);
animation: pulse-in 3s ease-in-out infinite;
pointer-events: none;
}
.transfer-label {
position: absolute;
top: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
z-index: 2;
}
.transfer-label.episodic {
left: 6%;
}
.transfer-label.semantic {
right: 6%;
}
.label-tag {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(0, 0, 0, 0.35);
color: #e0e0ff;
}
.transfer-label.episodic .label-tag {
border-color: rgba(16, 185, 129, 0.5);
color: #10b981;
}
.transfer-label.semantic .label-tag {
border-color: rgba(192, 132, 252, 0.5);
color: #c084fc;
}
.label-sub {
font-size: 9px;
color: var(--color-dim);
letter-spacing: 0.1em;
}
.divider-line {
position: absolute;
top: 15%;
bottom: 15%;
left: 50%;
width: 1px;
background: linear-gradient(180deg, transparent, rgba(168, 85, 247, 0.35), transparent);
transform: translateX(-0.5px);
}
@keyframes card-float {
0%, 100% { translate: 0 0; }
25% { translate: 2px -3px; }
50% { translate: -2px 2px; }
75% { translate: 3px 1px; }
}
@keyframes card-pulse {
0%, 100% { filter: brightness(1) hue-rotate(var(--card-hue, 0deg)); }
50% { filter: brightness(1.3) hue-rotate(var(--card-hue, 0deg)); }
}
@keyframes dissolve {
0% { opacity: 1; transform: scale(1); filter: blur(0); }
60% { opacity: 0.3; filter: blur(2px); }
100% { opacity: 0; transform: scale(0.5); filter: blur(6px); }
}
@keyframes pulse-in {
0%, 100% { opacity: 0.3; transform: translate(-50%, -50%) scale(1); }
50% { opacity: 0.7; transform: translate(-50%, -50%) scale(1.15); }
}
@media (prefers-reduced-motion: reduce) {
.memory-card { animation: none; }
.replay-pulse { animation: none; }
.memory-card.is-pulsing { animation: none; }
}
</style>

View file

@ -0,0 +1,192 @@
<!--
DuplicateCluster — renders a single cosine-similarity cluster from the
`find_duplicates` MCP tool. Shows similarity bar (color-coded by severity),
stacked memory cards with type/retention/tags/date, and action controls
(Merge all → highest-retention winner, Review → expand, Dismiss → hide).
Pure helpers live in `./duplicates-helpers.ts` and are unit-tested there.
Keep this file focused on rendering + glue.
-->
<script lang="ts">
import { NODE_TYPE_COLORS } from '$types';
import {
similarityBandColor,
similarityBandLabel,
retentionColor,
pickWinner,
previewContent,
formatDate,
safeTags,
} from './duplicates-helpers';
interface ClusterMemory {
id: string;
content: string;
nodeType: string;
tags: string[];
retention: number;
createdAt: string;
}
interface Props {
similarity: number;
memories: ClusterMemory[];
suggestedAction: 'merge' | 'review';
onDismiss?: () => void;
onMerge?: (winnerId: string, loserIds: string[]) => void;
}
let { similarity, memories, suggestedAction, onDismiss, onMerge }: Props = $props();
let expanded = $state(false);
// Winner = highest retention; others get merged into it. Stable tie-break
// (first-wins). pickWinner returns null for empty input — render-guarded below.
const winner = $derived(pickWinner(memories));
const losers = $derived(
winner ? memories.filter((m) => m.id !== winner.id).map((m) => m.id) : []
);
function handleMerge() {
if (onMerge && winner) onMerge(winner.id, losers);
}
</script>
{#if memories.length > 0 && winner}
<div
class="glass-panel rounded-2xl p-5 space-y-4 transition-all duration-300 hover:border-synapse/20"
>
<!-- Header row: similarity bar + suggested action badge -->
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0 space-y-1.5">
<div class="flex items-center gap-3">
<span
class="text-sm font-semibold"
style="color: {similarityBandColor(similarity)}"
>
{(similarity * 100).toFixed(1)}%
</span>
<span class="text-xs text-dim">{similarityBandLabel(similarity)}</span>
<span class="text-xs text-muted">· {memories.length} memories</span>
</div>
<div
class="h-2 w-full overflow-hidden rounded-full bg-deep/60"
role="progressbar"
aria-label="Cosine similarity"
aria-valuenow={Math.round(similarity * 100)}
aria-valuemin="0"
aria-valuemax="100"
>
<div
class="h-full rounded-full transition-all duration-500"
style="width: {(similarity * 100).toFixed(1)}%; background: {similarityBandColor(
similarity
)}; box-shadow: 0 0 12px {similarityBandColor(similarity)}66"
></div>
</div>
</div>
<!-- Suggested action badge — dream (review) or recall (merge) -->
<span
class="flex-shrink-0 rounded-full border px-3 py-1 text-xs font-medium {suggestedAction ===
'merge'
? 'border-recall/40 bg-recall/10 text-recall'
: 'border-dream-glow/40 bg-dream/10 text-dream-glow'}"
>
Suggested: {suggestedAction === 'merge' ? 'Merge' : 'Review'}
</span>
</div>
<!-- Stacked memory cards -->
<div class="space-y-2">
{#each memories as memory (memory.id)}
<div
class="group flex items-start gap-3 rounded-xl border border-synapse/5 bg-white/[0.02] p-3 transition-all duration-200 hover:border-synapse/20 hover:bg-white/[0.04] {memory.id ===
winner.id
? 'ring-1 ring-recall/30'
: ''}"
>
<!-- Type dot -->
<span
class="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full"
style="background: {NODE_TYPE_COLORS[memory.nodeType] || '#8B95A5'}"
title={memory.nodeType}
></span>
<div class="flex-1 min-w-0 space-y-1.5">
<!-- Type + tags + winner flag -->
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-xs text-dim">{memory.nodeType}</span>
{#if memory.id === winner.id}
<span class="rounded bg-recall/15 px-1.5 py-0.5 text-[10px] font-medium text-recall">
WINNER
</span>
{/if}
{#each safeTags(memory.tags, 4) as tag}
<span class="rounded bg-white/[0.04] px-1.5 py-0.5 text-[10px] text-muted"
>{tag}</span
>
{/each}
</div>
<!-- Content preview (or full content if expanded) -->
<p class="text-sm text-text leading-relaxed {expanded ? 'whitespace-pre-wrap' : ''}">
{expanded ? memory.content : previewContent(memory.content)}
</p>
<!-- Date (empty string for invalid/missing — no "Invalid Date") -->
{#if formatDate(memory.createdAt)}
<div class="text-[11px] text-muted">
{formatDate(memory.createdAt)}
</div>
{/if}
</div>
<!-- Retention bar + percent (right rail) -->
<div class="flex flex-shrink-0 flex-col items-end gap-1">
<div class="h-1.5 w-12 overflow-hidden rounded-full bg-deep">
<div
class="h-full rounded-full"
style="width: {memory.retention * 100}%; background: {retentionColor(
memory.retention
)}"
></div>
</div>
<span class="text-[11px] text-muted">
{(memory.retention * 100).toFixed(0)}%
</span>
</div>
</div>
{/each}
</div>
<!-- Actions — native <button> elements, fully keyboard-accessible. -->
<div class="flex flex-wrap items-center gap-2 pt-1">
<button
type="button"
onclick={handleMerge}
aria-label="Merge all memories into the highest-retention winner"
class="rounded-lg bg-recall/20 px-3 py-1.5 text-xs font-medium text-recall transition hover:bg-recall/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-recall/60"
title="Merge all into highest-retention memory ({(winner.retention * 100).toFixed(0)}%)"
>
Merge all → winner
</button>
<button
type="button"
onclick={() => (expanded = !expanded)}
aria-expanded={expanded}
class="rounded-lg bg-dream/20 px-3 py-1.5 text-xs font-medium text-dream-glow transition hover:bg-dream/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-dream-glow/60"
>
{expanded ? 'Collapse' : 'Review'}
</button>
<button
type="button"
onclick={onDismiss}
aria-label="Dismiss cluster for this session"
class="ml-auto rounded-lg bg-white/[0.04] px-3 py-1.5 text-xs text-dim transition hover:bg-white/[0.08] hover:text-text focus:outline-none focus-visible:ring-2 focus-visible:ring-synapse/60"
>
Dismiss cluster
</button>
</div>
</div>
{/if}

View file

@ -0,0 +1,157 @@
<script lang="ts">
import {
roleMetaFor,
trustColor,
trustPercent,
nodeTypeColor,
formatDate,
shortenId,
type EvidenceRole,
} from './reasoning-helpers';
interface Props {
id: string;
trust: number; // 0-1
date: string; // ISO
role: EvidenceRole;
preview: string;
nodeType?: string;
index?: number; // for staggered animation
}
let { id, trust, date, role, preview, nodeType, index = 0 }: Props = $props();
// Clamp for display — delegated to pure helper for testability.
const trustPct = $derived(trustPercent(trust));
const meta = $derived(roleMetaFor(role));
const shortId = $derived(shortenId(id));
const typeColor = $derived(nodeTypeColor(nodeType));
</script>
<div
class="evidence-card glass rounded-xl p-4 space-y-3 transition relative"
class:contradicting={role === 'contradicting'}
class:primary={role === 'primary'}
class:superseded={role === 'superseded'}
style="animation-delay: {index * 80}ms;"
data-evidence-id={id}
>
<!-- Role banner + id -->
<div class="flex items-center justify-between text-[10px] uppercase tracking-wider">
<div class="flex items-center gap-2">
<span class="role-pill px-2 py-0.5 rounded text-[10px]">
<span class="mr-1">{meta.icon}</span>{meta.label}
</span>
{#if nodeType}
<span class="px-1.5 py-0.5 rounded bg-white/[0.04]" style="color: {typeColor}">
{nodeType}
</span>
{/if}
</div>
<span class="text-muted font-mono text-[10px]" title={id}>#{shortId}</span>
</div>
<!-- Preview -->
<p class="text-sm text-text leading-relaxed line-clamp-4">{preview}</p>
<!-- Trust bar -->
<div class="space-y-1.5">
<div class="flex items-center justify-between text-[10px]">
<span class="text-dim uppercase tracking-wider">Trust</span>
<span class="font-mono" style="color: {trustColor(trust)}">{trustPct.toFixed(0)}%</span>
</div>
<div class="h-1.5 bg-deep rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-700 trust-fill"
style="width: {trustPct}%; background: {trustColor(trust)}; box-shadow: 0 0 8px {trustColor(trust)}80;"
></div>
</div>
</div>
<!-- Date -->
<div class="flex items-center justify-between text-[10px] text-muted pt-1">
<span>{formatDate(date)}</span>
<span class="font-mono opacity-60">FSRS · reps × retention</span>
</div>
</div>
<style>
.evidence-card {
animation: card-rise 600ms cubic-bezier(0.22, 0.8, 0.3, 1) backwards;
}
.evidence-card.primary {
border-color: rgba(99, 102, 241, 0.35) !important;
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, 0.04),
0 0 32px rgba(99, 102, 241, 0.18),
0 8px 32px rgba(0, 0, 0, 0.4);
}
.evidence-card.contradicting {
border-color: rgba(239, 68, 68, 0.45) !important;
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, 0.03),
0 0 28px rgba(239, 68, 68, 0.2),
0 8px 32px rgba(0, 0, 0, 0.4);
}
.evidence-card.superseded {
opacity: 0.55;
}
.evidence-card.superseded:hover {
opacity: 0.9;
}
.role-pill {
background: rgba(99, 102, 241, 0.12);
color: #c7cbff;
border: 1px solid rgba(99, 102, 241, 0.25);
}
.evidence-card.contradicting .role-pill {
background: rgba(239, 68, 68, 0.14);
color: #fecaca;
border-color: rgba(239, 68, 68, 0.4);
}
.evidence-card.primary .role-pill {
background: rgba(99, 102, 241, 0.22);
color: #a5b4ff;
border-color: rgba(99, 102, 241, 0.5);
}
.trust-fill {
animation: trust-sweep 1000ms cubic-bezier(0.22, 0.8, 0.3, 1) backwards;
}
@keyframes card-rise {
0% {
opacity: 0;
transform: translateY(12px) scale(0.98);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes trust-sweep {
0% {
width: 0% !important;
opacity: 0.4;
}
100% {
opacity: 1;
}
}
.line-clamp-4 {
display: -webkit-box;
-webkit-line-clamp: 4;
line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View file

@ -0,0 +1,344 @@
<script lang="ts">
import type { Memory } from '$types';
import { NODE_TYPE_COLORS } from '$types';
import {
startOfDay,
daysBetween,
isoDate,
gridStartForAnchor,
avgRetention as avgRetentionHelper,
} from './schedule-helpers';
type Props = {
memories: Memory[];
anchor?: Date;
};
let { memories, anchor = new Date() }: Props = $props();
// Build a 6-row x 7-col grid starting 2 weeks before today.
// Rows: 2 past weeks + 4 future weeks = 6 weeks = 42 cells.
// Align so the first row starts on the Sunday that is on or before
// (today - 14 days). This keeps today visible in week 3.
let today = $derived(startOfDay(anchor));
let gridStart = $derived(gridStartForAnchor(anchor));
type DayCell = {
date: Date;
key: string;
isToday: boolean;
inWindow: boolean; // within -14 to +28 days
memories: Memory[];
avgRetention: number;
};
// Bucket memories by their nextReviewAt day (YYYY-MM-DD).
let buckets = $derived(
(() => {
const map = new Map<string, Memory[]>();
for (const m of memories) {
if (!m.nextReviewAt) continue;
const d = new Date(m.nextReviewAt);
if (Number.isNaN(d.getTime())) continue;
const key = isoDate(startOfDay(d));
const arr = map.get(key);
if (arr) arr.push(m);
else map.set(key, [m]);
}
return map;
})()
);
let cells = $derived(
(() => {
const out: DayCell[] = [];
for (let i = 0; i < 42; i++) {
const d = new Date(gridStart);
d.setDate(d.getDate() + i);
const key = isoDate(d);
const ms = buckets.get(key) ?? [];
const delta = daysBetween(d, today);
out.push({
date: d,
key,
isToday: delta === 0,
inWindow: delta >= -14 && delta <= 28,
memories: ms,
avgRetention: avgRetentionHelper(ms)
});
}
return out;
})()
);
// Urgency coloring — emerald / amber / decay-red / synapse / muted.
function cellColor(cell: DayCell): { bg: string; border: string; text: string } {
if (cell.memories.length === 0) {
return { bg: 'rgba(255,255,255,0.02)', border: 'rgba(99,102,241,0.06)', text: '#4a4a7a' };
}
const delta = daysBetween(cell.date, today);
if (delta < -1) {
// Overdue (more than 1 day in the past) — decay-red
return {
bg: 'rgba(239,68,68,0.16)',
border: 'rgba(239,68,68,0.45)',
text: '#fca5a5'
};
}
if (delta >= -1 && delta <= 0) {
// Due today (or yesterday, just at the threshold) — amber
return {
bg: 'rgba(245,158,11,0.18)',
border: 'rgba(245,158,11,0.5)',
text: '#fcd34d'
};
}
if (delta > 0 && delta <= 7) {
// Due within 7 days — synapse blue
return {
bg: 'rgba(99,102,241,0.16)',
border: 'rgba(99,102,241,0.45)',
text: '#a5b4fc'
};
}
// >7 days out — muted
return {
bg: 'rgba(168,85,247,0.08)',
border: 'rgba(168,85,247,0.2)',
text: '#c084fc'
};
}
let selectedKey: string | null = $state(null);
let selectedCell = $derived(cells.find((c) => c.key === selectedKey) ?? null);
function toggle(key: string) {
selectedKey = selectedKey === key ? null : key;
}
// Retention sparkline — one point per day in the window, average retention
// of memories due that day. Width 100% x height 48px SVG.
const SPARK_W = 600;
const SPARK_H = 56;
let sparkPoints = $derived(
(() => {
const pts: { x: number; y: number; r: number; count: number }[] = [];
const n = cells.length;
for (let i = 0; i < n; i++) {
const c = cells[i];
const x = (i / (n - 1)) * SPARK_W;
// Invert Y: higher retention = higher on chart = smaller y
const r = c.avgRetention;
const y = SPARK_H - 6 - r * (SPARK_H - 12);
pts.push({ x, y, r, count: c.memories.length });
}
return pts;
})()
);
let sparkPath = $derived(
(() => {
const valid = sparkPoints.filter((p) => p.count > 0);
if (valid.length === 0) return '';
return valid.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' ');
})()
);
// Today's x-position on the sparkline, so the viewer can anchor the trend.
let todayIndex = $derived(cells.findIndex((c) => c.isToday));
let todayX = $derived(todayIndex >= 0 ? (todayIndex / (cells.length - 1)) * SPARK_W : -1);
const DOW_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
function shortDate(d: Date): string {
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
function fullDate(d: Date): string {
return d.toLocaleDateString(undefined, {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric'
});
}
</script>
<div class="space-y-4">
<!-- Retention sparkline -->
<div class="p-4 glass-subtle rounded-xl">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-dim font-medium">Avg retention of memories due — last 2 weeks → next 4</span>
<div class="flex items-center gap-3 text-[10px] text-muted">
<span class="flex items-center gap-1"><span class="w-2 h-0.5 bg-recall"></span>retention</span>
<span class="flex items-center gap-1"><span class="w-px h-3 bg-synapse-glow"></span>today</span>
</div>
</div>
<svg viewBox="0 0 {SPARK_W} {SPARK_H}" preserveAspectRatio="none" class="w-full h-12 block" aria-hidden="true">
<!-- Baselines at 30% / 70% -->
<line x1="0" x2={SPARK_W} y1={SPARK_H - 6 - 0.3 * (SPARK_H - 12)} y2={SPARK_H - 6 - 0.3 * (SPARK_H - 12)}
stroke="rgba(239,68,68,0.18)" stroke-dasharray="2 4" stroke-width="1" />
<line x1="0" x2={SPARK_W} y1={SPARK_H - 6 - 0.7 * (SPARK_H - 12)} y2={SPARK_H - 6 - 0.7 * (SPARK_H - 12)}
stroke="rgba(16,185,129,0.18)" stroke-dasharray="2 4" stroke-width="1" />
{#if todayX >= 0}
<line x1={todayX} x2={todayX} y1="0" y2={SPARK_H}
stroke="rgba(129,140,248,0.5)" stroke-width="1" />
{/if}
{#if sparkPath}
<path d={sparkPath} fill="none" stroke="var(--color-recall)" stroke-width="1.5" stroke-linejoin="round" />
{/if}
{#each sparkPoints as p}
{#if p.count > 0}
<circle cx={p.x} cy={p.y} r="1.5" fill="var(--color-recall)" />
{/if}
{/each}
</svg>
</div>
<!-- Day-of-week header -->
<div class="grid grid-cols-7 gap-2 px-1">
{#each DOW_LABELS as label}
<div class="text-[10px] text-muted font-mono uppercase tracking-wider text-center">{label}</div>
{/each}
</div>
<!-- Calendar grid -->
<div class="grid grid-cols-7 gap-2">
{#each cells as cell (cell.key)}
{@const colors = cellColor(cell)}
<button
type="button"
onclick={() => toggle(cell.key)}
disabled={cell.memories.length === 0}
class="relative aspect-square rounded-lg p-2 text-left transition-all duration-200
{cell.inWindow ? 'opacity-100' : 'opacity-35'}
{cell.memories.length > 0 ? 'hover:scale-[1.03] cursor-pointer' : 'cursor-default'}
{cell.isToday ? 'ring-2 ring-synapse/60 shadow-[0_0_16px_rgba(99,102,241,0.3)]' : ''}
{selectedKey === cell.key ? 'ring-2 ring-dream/60 shadow-[0_0_16px_rgba(168,85,247,0.3)]' : ''}"
style="background: {colors.bg}; border: 1px solid {colors.border};"
title={`${fullDate(cell.date)}${cell.memories.length} due`}
>
<div class="flex flex-col h-full">
<div class="flex items-start justify-between">
<span class="text-[10px] font-mono {cell.isToday ? 'text-synapse-glow font-bold' : 'text-dim'}">
{cell.date.getDate()}
</span>
{#if cell.date.getDate() === 1}
<span class="text-[9px] text-muted">{cell.date.toLocaleDateString(undefined, { month: 'short' })}</span>
{/if}
</div>
{#if cell.memories.length > 0}
<div class="flex-1 flex flex-col items-center justify-center gap-0.5">
<span class="text-base sm:text-lg font-bold leading-none" style="color: {colors.text}">
{cell.memories.length}
</span>
{#if cell.avgRetention > 0}
<span class="text-[9px] text-muted">{(cell.avgRetention * 100).toFixed(0)}%</span>
{/if}
</div>
{/if}
</div>
</button>
{/each}
</div>
<!-- Legend -->
<div class="flex items-center gap-4 text-[10px] text-muted flex-wrap px-1">
<span class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded" style="background: rgba(239,68,68,0.16); border: 1px solid rgba(239,68,68,0.45);"></span>
Overdue
</span>
<span class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded" style="background: rgba(245,158,11,0.18); border: 1px solid rgba(245,158,11,0.5);"></span>
Due today
</span>
<span class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded" style="background: rgba(99,102,241,0.16); border: 1px solid rgba(99,102,241,0.45);"></span>
Within 7 days
</span>
<span class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded" style="background: rgba(168,85,247,0.08); border: 1px solid rgba(168,85,247,0.2);"></span>
Future (8+ days)
</span>
</div>
<!-- Expanded day panel -->
{#if selectedCell && selectedCell.memories.length > 0}
<div class="p-5 glass rounded-xl space-y-3 animate-panel-in">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm text-bright font-semibold">{fullDate(selectedCell.date)}</h3>
<p class="text-xs text-dim mt-0.5">
{selectedCell.memories.length} memor{selectedCell.memories.length === 1 ? 'y' : 'ies'} due
· avg retention {(selectedCell.avgRetention * 100).toFixed(0)}%
</p>
</div>
<button
type="button"
onclick={() => (selectedKey = null)}
class="text-xs text-muted hover:text-dim px-2 py-1 rounded-lg hover:bg-white/[0.03]"
aria-label="Close"
>
close ×
</button>
</div>
<div class="space-y-2 max-h-96 overflow-y-auto pr-1">
{#each selectedCell.memories.slice(0, 100) as m (m.id)}
<div class="flex items-start gap-3 p-2.5 rounded-lg bg-white/[0.02] hover:bg-white/[0.04] transition">
<span
class="w-2 h-2 mt-1.5 rounded-full flex-shrink-0"
style="background: {NODE_TYPE_COLORS[m.nodeType] || '#8B95A5'}"
></span>
<div class="flex-1 min-w-0">
<p class="text-sm text-text leading-snug line-clamp-2">{m.content}</p>
<div class="flex items-center gap-2 mt-1 text-[10px] text-muted">
<span>{m.nodeType}</span>
{#if m.reviewCount !== undefined}
<span>· {m.reviewCount} review{m.reviewCount === 1 ? '' : 's'}</span>
{/if}
{#each m.tags.slice(0, 2) as tag}
<span class="px-1 py-0.5 bg-white/[0.04] rounded text-muted">{tag}</span>
{/each}
</div>
</div>
<div class="flex flex-col items-end gap-1 flex-shrink-0">
<div class="w-12 h-1 bg-deep rounded-full overflow-hidden">
<div
class="h-full rounded-full"
style="width: {m.retentionStrength * 100}%; background: {m.retentionStrength > 0.7
? 'var(--color-recall)'
: m.retentionStrength > 0.4
? 'var(--color-warning)'
: 'var(--color-decay)'}"
></div>
</div>
<span class="text-[10px] text-muted">{(m.retentionStrength * 100).toFixed(0)}%</span>
</div>
</div>
{/each}
{#if selectedCell.memories.length > 100}
<p class="text-xs text-muted text-center pt-2">
+{selectedCell.memories.length - 100} more
</p>
{/if}
</div>
</div>
{/if}
</div>
<style>
@keyframes panel-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-panel-in {
animation: panel-in 0.18s ease-out;
}
</style>

View file

@ -0,0 +1,174 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
AXIS_ORDER,
clampChannels,
radarRadius,
sizePreset,
} from './importance-helpers';
interface Props {
novelty: number;
arousal: number;
reward: number;
attention: number;
size?: 'sm' | 'md' | 'lg';
}
let { novelty, arousal, reward, attention, size = 'md' }: Props = $props();
// Size presets + padding + clamp logic live in `importance-helpers.ts` so
// they can be tested in the Vitest node env without a jsdom harness.
let pxSize = $derived(sizePreset(size));
let showLabels = $derived(size !== 'sm');
let radius = $derived(radarRadius(size));
let cx = $derived(pxSize / 2);
let cy = $derived(pxSize / 2);
// Axis labels live alongside AXIS_ORDER's keys/angles so the renderer
// never drifts from the helper geometry.
const AXIS_LABELS: Record<(typeof AXIS_ORDER)[number]['key'], string> = {
novelty: 'Novelty',
arousal: 'Arousal',
reward: 'Reward',
attention: 'Attention'
};
let values = $derived(clampChannels({ novelty, arousal, reward, attention }));
function pointAt(value: number, angle: number): [number, number] {
const r = value * radius;
return [cx + Math.cos(angle) * r, cy + Math.sin(angle) * r];
}
// Grid rings at 25/50/75/100%.
const RINGS = [0.25, 0.5, 0.75, 1];
function ringPath(frac: number): string {
const pts = AXIS_ORDER.map(({ angle }) => pointAt(frac, angle));
return (
pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0].toFixed(2)},${p[1].toFixed(2)}`).join(' ') + ' Z'
);
}
// Mount-time grow-from-center animation. We scale the values from 0 to 1
// over ~600ms with an easeOutCubic curve so the polygon blossoms instead of
// popping in. Reactive on prop change would also be nice but this matches
// the brief ("animated fill-in on mount").
let animProgress = $state(0);
onMount(() => {
const duration = 600;
const start = performance.now();
let raf = 0;
const tick = (now: number) => {
const t = Math.min(1, (now - start) / duration);
// easeOutCubic
animProgress = 1 - Math.pow(1 - t, 3);
if (t < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
});
let polygonPath = $derived.by(() => {
const scale = animProgress;
const pts = AXIS_ORDER.map(({ key, angle }) => pointAt(values[key] * scale, angle));
return (
pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0].toFixed(2)},${p[1].toFixed(2)}`).join(' ') + ' Z'
);
});
// Label positions sit slightly outside the 100% ring.
function labelPos(angle: number): { x: number; y: number; anchor: 'start' | 'middle' | 'end' } {
const offset = radius + (size === 'lg' ? 18 : 12);
const x = cx + Math.cos(angle) * offset;
const y = cy + Math.sin(angle) * offset;
let anchor: 'start' | 'middle' | 'end' = 'middle';
if (Math.abs(Math.cos(angle)) > 0.5) {
anchor = Math.cos(angle) > 0 ? 'start' : 'end';
}
return { x, y, anchor };
}
</script>
<svg
width={pxSize}
height={pxSize}
viewBox="0 0 {pxSize} {pxSize}"
role="img"
aria-label="Importance radar: novelty {(values.novelty * 100).toFixed(0)}%, arousal {(values.arousal * 100).toFixed(0)}%, reward {(values.reward * 100).toFixed(0)}%, attention {(values.attention * 100).toFixed(0)}%"
>
<!-- Concentric rings (grid) -->
{#each RINGS as ring}
<path
d={ringPath(ring)}
fill="none"
stroke="var(--color-muted)"
stroke-opacity={ring === 1 ? 0.45 : 0.18}
stroke-width={ring === 1 ? 1 : 0.75}
/>
{/each}
<!-- Spokes -->
{#each AXIS_ORDER as axis}
{@const [x, y] = pointAt(1, axis.angle)}
<line
x1={cx}
y1={cy}
x2={x}
y2={y}
stroke="var(--color-muted)"
stroke-opacity="0.2"
stroke-width="0.75"
/>
{/each}
<!-- Filled polygon -->
<path
d={polygonPath}
fill="var(--color-synapse-glow)"
fill-opacity="0.3"
stroke="var(--color-synapse-glow)"
stroke-width={size === 'sm' ? 1 : 1.5}
stroke-linejoin="round"
/>
<!-- Vertex dots at the animated positions, only on md/lg for clarity -->
{#if size !== 'sm'}
{#each AXIS_ORDER as axis}
{@const [px, py] = pointAt(values[axis.key] * animProgress, axis.angle)}
<circle cx={px} cy={py} r={size === 'lg' ? 3 : 2.25} fill="var(--color-synapse-glow)" />
{/each}
{/if}
<!-- Axis labels (name + value) -->
{#if showLabels}
{#each AXIS_ORDER as axis}
{@const pos = labelPos(axis.angle)}
<text
x={pos.x}
y={pos.y}
text-anchor={pos.anchor}
dominant-baseline="middle"
fill="var(--color-bright)"
font-size={size === 'lg' ? 12 : 10}
font-family="var(--font-mono)"
font-weight="600"
>
{(values[axis.key] * 100).toFixed(0)}%
</text>
<text
x={pos.x}
y={pos.y + (size === 'lg' ? 14 : 11)}
text-anchor={pos.anchor}
dominant-baseline="middle"
fill="var(--color-dim)"
font-size={size === 'lg' ? 10 : 8.5}
font-family="var(--font-mono)"
>
{AXIS_LABELS[axis.key]}
</text>
{/each}
{/if}
</svg>

View file

@ -0,0 +1,185 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
META,
generateMockAuditTrail,
relativeTime,
formatRetentionDelta,
splitVisible,
type AuditEvent
} from './audit-trail-helpers';
/**
* MemoryAuditTrail — per-memory "Sources" panel.
*
* Renders a vertical bioluminescent timeline of every event that has touched
* a memory: creation, accesses, promotions/demotions, edits, suppressions,
* dream-cycle activations, and reconsolidations (5-min labile window edits).
*
* Backend `/api/changelog?memory_id=X` does NOT yet exist. A typed mock
* fetcher lives in `audit-trail-helpers.ts` and will be swapped for
* `api.memoryChangelog(id)` once the backend route ships.
*/
interface Props {
memoryId: string;
}
let { memoryId }: Props = $props();
let events: AuditEvent[] = $state([]);
let loading = $state(true);
let errored = $state(false);
let showAllOlder = $state(false);
// TODO: swap for api.memoryChangelog(id) when backend ships
async function fetchAuditTrail(id: string): Promise<AuditEvent[]> {
return generateMockAuditTrail(id, Date.now());
}
onMount(async () => {
if (!memoryId) {
events = [];
loading = false;
return;
}
try {
events = await fetchAuditTrail(memoryId);
} catch {
events = [];
errored = true;
} finally {
loading = false;
}
});
const split = $derived(splitVisible(events, showAllOlder));
const visibleEvents = $derived(split.visible);
const hiddenCount = $derived(split.hiddenCount);
</script>
<div class="audit-trail space-y-3" aria-label="Audit trail">
{#if loading}
<div class="space-y-2">
{#each Array(5) as _}
<div class="h-10 glass-subtle rounded-lg animate-pulse"></div>
{/each}
</div>
{:else if errored}
<p class="text-xs text-decay italic">Audit trail failed to load.</p>
{:else if !memoryId}
<p class="text-xs text-muted italic">No memory selected.</p>
{:else if events.length === 0}
<p class="text-xs text-muted italic">No audit events recorded yet.</p>
{:else}
<ol class="relative pl-6 border-l border-synapse/15 space-y-3">
{#each visibleEvents as ev, i (ev.timestamp + i)}
{@const m = META[ev.action]}
{@const delta = formatRetentionDelta(ev.old_value, ev.new_value)}
<li class="relative" style="animation-delay: {i * 40}ms;">
<!-- Marker -->
<span
class="marker absolute -left-[29px] top-0.5 w-4 h-4 flex items-center justify-center rounded-full"
style="background: rgba(10,10,26,0.9); box-shadow: 0 0 10px {m.color}88; border: 1px solid {m.color};"
aria-hidden="true"
>
{#if m.kind === 'dot'}
<span class="w-1.5 h-1.5 rounded-full" style="background: {m.color};"></span>
{:else if m.kind === 'ring'}
<span
class="w-2 h-2 rounded-full border"
style="border-color: {m.color}; background: transparent;"
></span>
{:else if m.kind === 'arrow-up'}
<svg viewBox="0 0 12 12" class="w-2.5 h-2.5" fill="none" stroke={m.color} stroke-width="2">
<path d="M6 10V2M3 5l3-3 3 3" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{:else if m.kind === 'arrow-down'}
<svg viewBox="0 0 12 12" class="w-2.5 h-2.5" fill="none" stroke={m.color} stroke-width="2">
<path d="M6 2v8M3 7l3 3 3-3" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{:else if m.kind === 'pencil'}
<svg viewBox="0 0 12 12" class="w-2.5 h-2.5" fill="none" stroke={m.color} stroke-width="1.5">
<path d="M8.5 1.5l2 2L4 10l-3 1 1-3 6.5-6.5z" stroke-linejoin="round" />
</svg>
{:else if m.kind === 'x'}
<svg viewBox="0 0 12 12" class="w-2.5 h-2.5" fill="none" stroke={m.color} stroke-width="2">
<path d="M2 2l8 8M10 2l-8 8" stroke-linecap="round" />
</svg>
{:else if m.kind === 'star'}
<svg viewBox="0 0 12 12" class="w-2.5 h-2.5" fill={m.color} stroke="none">
<path d="M6 0.5l1.4 3.3 3.6.3-2.7 2.4.8 3.5L6 8.2l-3.1 1.8.8-3.5L1 4.1l3.6-.3L6 .5z" />
</svg>
{:else if m.kind === 'circle-arrow'}
<svg viewBox="0 0 12 12" class="w-2.5 h-2.5" fill="none" stroke={m.color} stroke-width="1.5">
<path d="M10 6a4 4 0 1 1-1.2-2.8" stroke-linecap="round" />
<path d="M10 1.5V4H7.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{/if}
</span>
<!-- Event content -->
<div class="glass-subtle rounded-lg px-3 py-2 space-y-1">
<div class="flex items-center justify-between gap-2 flex-wrap">
<div class="flex items-center gap-2 text-xs">
<span class="font-semibold" style="color: {m.color};">{m.label}</span>
{#if ev.triggered_by}
<span class="text-muted font-mono text-[10px]">{ev.triggered_by}</span>
{/if}
</div>
<span class="text-[10px] text-muted font-mono" title={new Date(ev.timestamp).toLocaleString()}>
{relativeTime(ev.timestamp)}
</span>
</div>
{#if delta}
<div class="text-[11px] text-dim font-mono">
retention {delta}
</div>
{/if}
{#if ev.reason}
<div class="text-[11px] text-dim italic">{ev.reason}</div>
{/if}
</div>
</li>
{/each}
</ol>
{#if hiddenCount > 0}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
showAllOlder = !showAllOlder;
}}
class="text-xs text-synapse-glow hover:text-bright transition-colors underline-offset-4 hover:underline"
>
{showAllOlder ? 'Hide older events' : `Show ${hiddenCount} older event${hiddenCount === 1 ? '' : 's'}`}
</button>
{/if}
{/if}
</div>
<style>
.audit-trail :global(ol > li) {
animation: event-rise 400ms cubic-bezier(0.22, 0.8, 0.3, 1) backwards;
}
@keyframes event-rise {
0% {
opacity: 0;
transform: translateX(6px);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
:global(.audit-trail .marker) {
transition: transform 200ms ease;
}
:global(.audit-trail li:hover .marker) {
transform: scale(1.15);
}
</style>

View file

@ -0,0 +1,251 @@
<!--
PatternTransferHeatmap — CrossProjectLearner visualization.
Symmetric N×N project grid. Cell (row=A, col=B) intensity encodes how many
patterns were learned in project A and reused in project B. Diagonal =
self-transfer count (project reusing its own patterns).
Color scale: muted (no transfers) → synapse glow → dream glow for high
transfer counts. Cells are clickable (filters sidebar to A → B pairs) and
hoverable (tooltip with count + top 3 pattern names).
Responsive: the grid collapses to a vertical list of "A → B : count" rows
on small viewports so the matrix is still scannable on mobile.
-->
<script lang="ts">
import {
buildTransferMatrix,
flattenNonZero,
matrixMaxCount,
shortProjectName,
type PatternCategory,
type TransferPatternLike,
} from './patterns-helpers';
interface Pattern extends TransferPatternLike {
category: PatternCategory;
last_used: string;
confidence: number;
}
interface Props {
projects: string[];
patterns: Pattern[];
selectedCell: { from: string; to: string } | null;
onCellClick: (from: string, to: string) => void;
}
let { projects, patterns, selectedCell, onCellClick }: Props = $props();
// Matrix build, max-count, and non-zero flattening all live in
// `patterns-helpers.ts` so they can be unit tested in the Vitest node env.
const matrix = $derived(buildTransferMatrix(projects, patterns));
const maxCount = $derived(matrixMaxCount(projects, matrix) || 1);
// Hover tooltip state
let hoveredCell = $state<{ from: string; to: string; x: number; y: number } | null>(null);
function cellStyle(count: number): string {
if (count === 0) {
return 'background: rgba(255,255,255,0.02); border-color: rgba(99,102,241,0.05);';
}
const intensity = count / maxCount; // 0..1
// Two-stop gradient: synapse (indigo) for low-mid → dream (purple) for high.
// Alpha ramps 0.10 → 0.80 so even low-count cells read clearly.
const alpha = 0.1 + intensity * 0.7;
if (intensity < 0.5) {
// Synapse-dominant
return `background: rgba(99, 102, 241, ${alpha.toFixed(3)}); border-color: rgba(129, 140, 248, ${(alpha * 0.6).toFixed(3)}); box-shadow: 0 0 ${(intensity * 14).toFixed(1)}px rgba(129, 140, 248, ${(intensity * 0.45).toFixed(3)});`;
} else {
// Dream-dominant for the hottest cells
const dreamIntensity = (intensity - 0.5) * 2; // 0..1 over upper half
const r = Math.round(99 + (168 - 99) * dreamIntensity);
const g = Math.round(102 + (85 - 102) * dreamIntensity);
const b = Math.round(241 + (247 - 241) * dreamIntensity);
return `background: rgba(${r}, ${g}, ${b}, ${alpha.toFixed(3)}); border-color: rgba(192, 132, 252, ${(alpha * 0.7).toFixed(3)}); box-shadow: 0 0 ${(6 + intensity * 18).toFixed(1)}px rgba(192, 132, 252, ${(intensity * 0.55).toFixed(3)});`;
}
}
function cellTextClass(count: number): string {
if (count === 0) return 'text-muted';
const intensity = count / maxCount;
if (intensity >= 0.5) return 'text-bright font-semibold';
if (intensity >= 0.2) return 'text-text';
return 'text-dim';
}
function handleCellHover(ev: MouseEvent, from: string, to: string) {
const target = ev.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
hoveredCell = {
from,
to,
x: rect.left + rect.width / 2,
y: rect.top
};
}
function handleCellLeave() {
hoveredCell = null;
}
// Axis labels are diagonal; trim long names via the shared helper.
const shortProject = shortProjectName;
function isSelected(from: string, to: string): boolean {
return selectedCell !== null && selectedCell.from === from && selectedCell.to === to;
}
// Flattened list for the mobile fallback: only non-zero cells, sorted desc.
const mobileList = $derived(flattenNonZero(projects, matrix));
</script>
<div class="glass-panel relative rounded-2xl p-5">
<!-- Desktop / tablet: grid heatmap -->
<div class="hidden md:block">
<div class="mb-3 flex items-center justify-between">
<div class="text-xs text-dim">
Rows = origin project · Columns = destination project
</div>
<!-- Legend gradient -->
<div class="flex items-center gap-2">
<span class="text-[10px] text-muted">0</span>
<div
class="h-2 w-32 rounded-full"
style="background: linear-gradient(to right, rgba(255,255,255,0.05), rgba(99,102,241,0.5), rgba(168,85,247,0.85));"
></div>
<span class="text-[10px] text-muted">{maxCount}</span>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full border-separate" style="border-spacing: 4px;">
<thead>
<tr>
<th class="w-24"></th>
{#each projects as proj (proj)}
<th
class="h-20 min-w-16 max-w-20 align-bottom"
title={proj}
>
<div
class="mx-auto flex h-20 w-6 items-end justify-center"
style="writing-mode: vertical-rl; transform: rotate(180deg);"
>
<span class="text-[11px] text-dim font-medium tracking-wide">
{shortProject(proj)}
</span>
</div>
</th>
{/each}
</tr>
</thead>
<tbody>
{#each projects as from (from)}
<tr>
<td class="w-24 pr-2 text-right text-[11px] text-dim" title={from}>
{shortProject(from)}
</td>
{#each projects as to (to)}
{@const cell = matrix[from][to]}
{@const isDiag = from === to}
<td class="p-0">
<button
type="button"
class="group relative h-10 w-full min-w-12 rounded-md border transition-all duration-200 hover:scale-110 hover:z-10 focus:outline-none focus:ring-2 focus:ring-synapse-glow"
style="{cellStyle(cell.count)} {isSelected(from, to)
? 'outline: 2px solid var(--color-dream-glow); outline-offset: 1px;'
: ''} {isDiag && cell.count > 0
? 'border-style: dashed;'
: ''}"
onclick={() => onCellClick(from, to)}
onmouseenter={(e) => handleCellHover(e, from, to)}
onmouseleave={handleCellLeave}
aria-label="{cell.count} patterns from {from} to {to}"
>
<span class="text-[11px] {cellTextClass(cell.count)}">
{cell.count || ''}
</span>
</button>
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Hover tooltip -->
{#if hoveredCell}
{@const cell = matrix[hoveredCell.from][hoveredCell.to]}
<div
class="glass-panel pointer-events-none fixed z-50 max-w-xs rounded-lg p-3 text-xs shadow-2xl"
style="left: {hoveredCell.x}px; top: {hoveredCell.y - 12}px; transform: translate(-50%, -100%);"
>
<div class="mb-1 flex items-center gap-2">
<span class="font-mono text-dim">{shortProject(hoveredCell.from)}</span>
<span class="text-synapse-glow"></span>
<span class="font-mono text-bright">{shortProject(hoveredCell.to)}</span>
</div>
<div class="mb-2 text-lg font-semibold text-bright">
{cell.count}
<span class="text-xs font-normal text-dim">
{cell.count === 1 ? 'pattern' : 'patterns'} transferred
</span>
</div>
{#if cell.topNames.length > 0}
<div class="space-y-1 border-t border-synapse/10 pt-2">
<div class="text-[10px] uppercase tracking-wider text-muted">Top patterns</div>
{#each cell.topNames as name}
<div class="truncate text-text">· {name}</div>
{/each}
</div>
{:else}
<div class="text-muted">No transfers recorded</div>
{/if}
</div>
{/if}
</div>
<!-- Mobile: vertical list of non-zero transfers -->
<div class="space-y-2 md:hidden">
<div class="mb-2 text-xs text-dim">
{mobileList.length} transfer pair{mobileList.length === 1 ? '' : 's'} · tap to filter
</div>
{#if mobileList.length === 0}
<div class="rounded-lg bg-white/[0.02] p-4 text-center text-xs text-muted">
No cross-project transfers recorded yet.
</div>
{:else}
{#each mobileList as row (row.from + '->' + row.to)}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border border-synapse/10 bg-white/[0.02] p-3 transition hover:border-synapse/30 hover:bg-white/[0.04] {isSelected(
row.from,
row.to
)
? 'ring-1 ring-dream-glow'
: ''}"
onclick={() => onCellClick(row.from, row.to)}
>
<div class="flex min-w-0 flex-col items-start gap-0.5">
<div class="flex items-center gap-1.5 text-xs">
<span class="font-mono text-dim">{shortProject(row.from)}</span>
<span class="text-synapse-glow"></span>
<span class="font-mono text-bright">{shortProject(row.to)}</span>
</div>
{#if row.topNames.length > 0}
<div class="truncate text-[11px] text-muted">
{row.topNames.join(' · ')}
</div>
{/if}
</div>
<span
class="ml-3 flex-shrink-0 rounded-full bg-synapse/15 px-2 py-0.5 text-xs font-semibold text-synapse-glow"
>
{row.count}
</span>
</button>
{/each}
{/if}
</div>
</div>

View file

@ -0,0 +1,259 @@
<script lang="ts">
interface StageResult {
label?: string;
value?: string | number;
}
interface Props {
intent?: string;
memoriesAnalyzed?: number;
evidenceCount?: number;
contradictionCount?: number;
supersededCount?: number;
running?: boolean; // when true, run the sequential light-up animation
// Optional per-stage hints (one-liners) — if provided, overrides defaults
stageHints?: Partial<Record<StageKey, string>>;
}
type StageKey =
| 'broad'
| 'spreading'
| 'fsrs'
| 'intent'
| 'supersession'
| 'contradiction'
| 'relation'
| 'template';
let {
intent = 'Synthesis',
memoriesAnalyzed = 0,
evidenceCount = 0,
contradictionCount = 0,
supersededCount = 0,
running = false,
stageHints = {}
}: Props = $props();
const STAGES: { key: StageKey; icon: string; label: string; base: string }[] = [
{
key: 'broad',
icon: '◎',
label: 'Broad Retrieval',
base: 'Hybrid BM25 + semantic (3x overfetch) then cross-encoder rerank'
},
{
key: 'spreading',
icon: '⟿',
label: 'Spreading Activation',
base: 'Collins & Loftus — expand via graph edges to surface what search missed'
},
{
key: 'fsrs',
icon: '▲',
label: 'FSRS Trust Scoring',
base: 'retention × stability × reps ÷ lapses — which memories have earned trust'
},
{
key: 'intent',
icon: '◆',
label: 'Intent Classification',
base: 'FactCheck / Timeline / RootCause / Comparison / Synthesis'
},
{
key: 'supersession',
icon: '↗',
label: 'Temporal Supersession',
base: 'Newer high-trust memories replace older ones on the same fact'
},
{
key: 'contradiction',
icon: '⚡',
label: 'Contradiction Analysis',
base: 'Only flag conflicts between memories where BOTH have trust > 0.3'
},
{
key: 'relation',
icon: '⬡',
label: 'Relation Assessment',
base: 'Per pair: Supports / Contradicts / Supersedes / Irrelevant'
},
{
key: 'template',
icon: '❖',
label: 'Template Reasoning',
base: 'Build the natural-language reasoning chain you validate'
}
];
// Dynamic one-liners reflecting the actual response — fall back to base
const computed: Partial<Record<StageKey, string>> = $derived({
broad: memoriesAnalyzed ? `Analyzed ${memoriesAnalyzed} memories · ${evidenceCount} survived ranking` : undefined,
intent: intent ? `Classified as ${intent}` : undefined,
supersession: supersededCount
? `${supersededCount} outdated memor${supersededCount === 1 ? 'y' : 'ies'} superseded`
: undefined,
contradiction: contradictionCount
? `${contradictionCount} real conflict${contradictionCount === 1 ? '' : 's'} between trusted memories`
: 'No conflicts between trusted memories'
});
function hintFor(key: StageKey, base: string): string {
return stageHints[key] ?? computed[key] ?? base;
}
</script>
<div class="reasoning-chain space-y-2" class:running>
{#each STAGES as stage, i (stage.key)}
<div
class="stage glass-subtle rounded-xl p-3 flex items-start gap-3 relative"
style="animation-delay: {i * 140}ms;"
>
<!-- Connector line down to next stage -->
{#if i < STAGES.length - 1}
<div class="connector" style="animation-delay: {i * 140 + 120}ms;"></div>
{/if}
<!-- Stage index + icon -->
<div class="stage-orb flex-shrink-0" style="animation-delay: {i * 140}ms;">
<span class="text-xs text-synapse-glow">{stage.icon}</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-0.5">
<span class="text-[10px] font-mono text-muted">0{i + 1}</span>
<span class="text-sm text-bright font-medium">{stage.label}</span>
</div>
<p class="text-xs text-dim leading-snug">{hintFor(stage.key, stage.base)}</p>
</div>
<span class="stage-pulse" aria-hidden="true"></span>
</div>
{/each}
</div>
<style>
.stage {
animation: stage-light 700ms cubic-bezier(0.22, 0.8, 0.3, 1) backwards;
position: relative;
border-color: rgba(99, 102, 241, 0.08);
}
.stage-orb {
width: 28px;
height: 28px;
border-radius: 50%;
background: radial-gradient(
circle at 30% 30%,
rgba(129, 140, 248, 0.25),
rgba(99, 102, 241, 0.05)
);
border: 1px solid rgba(99, 102, 241, 0.3);
display: flex;
align-items: center;
justify-content: center;
position: relative;
animation: orb-glow 700ms cubic-bezier(0.22, 0.8, 0.3, 1) backwards;
}
.stage-pulse {
position: absolute;
inset: 0;
border-radius: 12px;
border: 1px solid rgba(129, 140, 248, 0);
pointer-events: none;
animation: pulse-ring 700ms cubic-bezier(0.22, 0.8, 0.3, 1) backwards;
}
.connector {
position: absolute;
left: 22px;
top: 100%;
width: 1px;
height: 8px;
background: linear-gradient(180deg, rgba(129, 140, 248, 0.5), rgba(168, 85, 247, 0.15));
animation: connector-draw 500ms ease-out backwards;
}
.running .stage {
animation: stage-light 700ms cubic-bezier(0.22, 0.8, 0.3, 1) backwards,
stage-flicker 2400ms ease-in-out infinite;
}
@keyframes stage-light {
0% {
opacity: 0;
transform: translateX(-8px);
border-color: rgba(99, 102, 241, 0);
}
60% {
opacity: 1;
border-color: rgba(129, 140, 248, 0.35);
}
100% {
opacity: 1;
transform: translateX(0);
border-color: rgba(99, 102, 241, 0.08);
}
}
@keyframes orb-glow {
0% {
transform: scale(0.6);
opacity: 0;
box-shadow: 0 0 0 rgba(129, 140, 248, 0);
}
60% {
transform: scale(1.15);
opacity: 1;
box-shadow: 0 0 24px rgba(129, 140, 248, 0.8);
}
100% {
transform: scale(1);
box-shadow: 0 0 10px rgba(129, 140, 248, 0.35);
}
}
@keyframes pulse-ring {
0% {
transform: scale(0.96);
opacity: 0;
border-color: rgba(129, 140, 248, 0);
}
70% {
transform: scale(1);
opacity: 1;
border-color: rgba(129, 140, 248, 0.4);
box-shadow: 0 0 20px rgba(129, 140, 248, 0.25);
}
100% {
transform: scale(1.01);
opacity: 0;
border-color: rgba(129, 140, 248, 0);
box-shadow: 0 0 0 rgba(129, 140, 248, 0);
}
}
@keyframes connector-draw {
0% {
transform: scaleY(0);
transform-origin: top;
opacity: 0;
}
100% {
transform: scaleY(1);
transform-origin: top;
opacity: 1;
}
}
@keyframes stage-flicker {
0%,
100% {
border-color: rgba(99, 102, 241, 0.08);
}
50% {
border-color: rgba(129, 140, 248, 0.25);
}
}
</style>

View file

@ -0,0 +1,175 @@
<!--
ThemeToggle — closes GitHub issue #11.
Small 30px icon button. Click cycles dark → light → auto → dark.
Shows the current mode via icon + aria-label + tooltip. Smooth 200ms
fade/scale crossfade between icons so the state change feels tactile.
Theme overrides live in $stores/theme (injected stylesheet approach —
app.css is never mutated). The button itself uses existing glass
tokens so it drops cleanly into the sidebar/header.
-->
<script lang="ts">
import { theme, cycleTheme, type Theme } from '$stores/theme';
// Cycle order determines the label shown in the tooltip/aria.
const LABELS: Record<Theme, string> = {
dark: 'Dark',
light: 'Light',
auto: 'Auto (system)'
};
const NEXT: Record<Theme, Theme> = {
dark: 'light',
light: 'auto',
auto: 'dark'
};
let current = $derived($theme);
let nextMode = $derived(NEXT[current]);
let ariaLabel = $derived(`Toggle theme: ${LABELS[current]} (click for ${LABELS[nextMode]})`);
</script>
<button
type="button"
class="theme-toggle"
aria-label={ariaLabel}
title={ariaLabel}
onclick={cycleTheme}
data-mode={current}
>
<!-- Three SVG icons stacked, crossfade by opacity. Only the active
one is visible; aria-hidden on all since the button label
carries the semantics. -->
<span class="icon-wrap">
<!-- MOON (dark mode) -->
<svg
class="icon"
class:active={current === 'dark'}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
<!-- SUN (light mode) -->
<svg
class="icon"
class:active={current === 'light'}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
<!-- AUTO (half-moon with gear teeth) -->
<svg
class="icon"
class:active={current === 'auto'}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<!-- Left half filled (dark side), right half outlined (light side) -->
<circle cx="12" cy="12" r="8" />
<path d="M12 4 A8 8 0 0 0 12 20 Z" fill="currentColor" stroke="none" />
<!-- Tiny gear notches to signal 'system / automatic' -->
<path d="M12 2v1.5M12 20.5V22M3.5 12H2M22 12h-1.5" />
</svg>
</span>
</button>
<style>
.theme-toggle {
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border-radius: 8px;
background: rgba(99, 102, 241, 0.06);
border: 1px solid rgba(99, 102, 241, 0.14);
color: var(--color-text);
cursor: pointer;
transition:
background 200ms ease,
border-color 200ms ease,
color 200ms ease,
transform 120ms ease;
-webkit-tap-highlight-color: transparent;
}
.theme-toggle:hover {
background: rgba(99, 102, 241, 0.14);
border-color: rgba(99, 102, 241, 0.3);
color: var(--color-bright);
}
.theme-toggle:active {
transform: scale(0.94);
}
.theme-toggle:focus-visible {
outline: 1px solid var(--color-synapse);
outline-offset: 2px;
}
.icon-wrap {
position: relative;
width: 18px;
height: 18px;
display: inline-block;
}
.icon {
position: absolute;
inset: 0;
width: 18px;
height: 18px;
opacity: 0;
transform: scale(0.7) rotate(-30deg);
transition:
opacity 200ms ease,
transform 200ms cubic-bezier(0.16, 1, 0.3, 1);
pointer-events: none;
}
.icon.active {
opacity: 1;
transform: scale(1) rotate(0deg);
}
/* Subtle mode-specific accent tint so the button itself reflects
* the active mode at a glance. */
.theme-toggle[data-mode='dark'] {
color: var(--color-synapse-glow, #818cf8);
}
.theme-toggle[data-mode='light'] {
color: var(--color-warning, #f59e0b);
}
.theme-toggle[data-mode='auto'] {
color: var(--color-dream-glow, #c084fc);
}
@media (prefers-reduced-motion: reduce) {
.theme-toggle,
.icon {
transition: none;
}
}
</style>

View file

@ -0,0 +1,464 @@
/**
* Unit tests for Spreading Activation helpers.
*
* Pure-logic coverage only the SVG render layer is not exercised here
* (no jsdom). The six concerns we test are the ones that actually decide
* whether the burst looks right:
*
* 1. Per-tick decay math (Collins & Loftus 1975, 0.93/frame)
* 2. Compound decay after N ticks
* 3. Threshold filter (activation < 0.05 invisible)
* 4. Concentric-ring placement around a source (8-per-ring, even angles)
* 5. Color mapping (source synapse-glow, unknown type fallback)
* 6. Staggered edge delay (rank ordering, ring-2 bonus)
* 7. Event-feed filter (only NEW ActivationSpread events since lastSeen)
*
* The test environment is Node (vitest `environment: 'node'`) the same
* harness the graph + dream helper tests use.
*/
import { describe, it, expect } from 'vitest';
import {
DECAY,
FALLBACK_COLOR,
MIN_VISIBLE,
RING_GAP,
RING_1_CAPACITY,
SOURCE_COLOR,
STAGGER_PER_RANK,
STAGGER_RING_2_BONUS,
activationColor,
applyDecay,
compoundDecay,
computeRing,
edgeStagger,
filterNewSpreadEvents,
initialActivation,
isVisible,
layoutNeighbours,
ringPositions,
ticksUntilInvisible,
} from '../activation-helpers';
import { NODE_TYPE_COLORS, type VestigeEvent } from '$types';
// ---------------------------------------------------------------------------
// 1. Decay math — single tick
// ---------------------------------------------------------------------------
describe('applyDecay (Collins & Loftus 1975, 0.93/frame)', () => {
it('multiplies activation by 0.93 per tick', () => {
expect(applyDecay(1)).toBeCloseTo(0.93, 10);
});
it('matches the documented constant', () => {
expect(DECAY).toBe(0.93);
});
it('returns 0 for zero / negative / non-finite input', () => {
expect(applyDecay(0)).toBe(0);
expect(applyDecay(-0.5)).toBe(0);
expect(applyDecay(Number.NaN)).toBe(0);
expect(applyDecay(Number.POSITIVE_INFINITY)).toBe(0);
});
it('preserves strict monotonic decrease', () => {
let a = 1;
let prev = a;
for (let i = 0; i < 50; i++) {
a = applyDecay(a);
if (a === 0) break;
expect(a).toBeLessThan(prev);
prev = a;
}
});
});
// ---------------------------------------------------------------------------
// 2. Compound decay — N ticks
// ---------------------------------------------------------------------------
describe('compoundDecay', () => {
it('0 ticks returns the input unchanged', () => {
expect(compoundDecay(0.8, 0)).toBe(0.8);
});
it('N ticks equals applyDecay called N times', () => {
let iterative = 1;
for (let i = 0; i < 10; i++) iterative = applyDecay(iterative);
expect(compoundDecay(1, 10)).toBeCloseTo(iterative, 10);
});
it('5 ticks from 1.0 lands in the 0.69..0.70 band', () => {
// 0.93^5 ≈ 0.6957
const result = compoundDecay(1, 5);
expect(result).toBeGreaterThan(0.69);
expect(result).toBeLessThan(0.7);
});
it('treats negative tick counts as no-op', () => {
expect(compoundDecay(0.5, -3)).toBe(0.5);
});
});
// ---------------------------------------------------------------------------
// 3. Threshold filter — fade/remove below MIN_VISIBLE
// ---------------------------------------------------------------------------
describe('isVisible / MIN_VISIBLE threshold', () => {
it('MIN_VISIBLE is exactly 0.05', () => {
expect(MIN_VISIBLE).toBe(0.05);
});
it('returns true at exactly the threshold (inclusive floor)', () => {
expect(isVisible(0.05)).toBe(true);
});
it('returns false just below the threshold', () => {
expect(isVisible(0.0499)).toBe(false);
});
it('returns false for zero / negative / NaN', () => {
expect(isVisible(0)).toBe(false);
expect(isVisible(-0.1)).toBe(false);
expect(isVisible(Number.NaN)).toBe(false);
});
it('returns true for typical full-activation source', () => {
expect(isVisible(1)).toBe(true);
});
});
describe('ticksUntilInvisible', () => {
it('returns 0 when input is already at/below MIN_VISIBLE', () => {
expect(ticksUntilInvisible(MIN_VISIBLE)).toBe(0);
expect(ticksUntilInvisible(0.03)).toBe(0);
expect(ticksUntilInvisible(0)).toBe(0);
});
it('produces a count that actually crosses the threshold', () => {
const n = ticksUntilInvisible(1);
expect(n).toBeGreaterThan(0);
// After n ticks we should be BELOW the threshold...
expect(compoundDecay(1, n)).toBeLessThan(MIN_VISIBLE);
// ...but one fewer tick should still be visible.
expect(compoundDecay(1, n - 1)).toBeGreaterThanOrEqual(MIN_VISIBLE);
});
it('takes ~42 ticks for a full-strength burst to fade to threshold', () => {
// log(0.05) / log(0.93) ≈ 41.27 → ceil → 42
expect(ticksUntilInvisible(1)).toBe(42);
});
});
// ---------------------------------------------------------------------------
// 4. Ring placement
// ---------------------------------------------------------------------------
describe('computeRing', () => {
it('ranks 0..7 land on ring 1', () => {
for (let r = 0; r < RING_1_CAPACITY; r++) {
expect(computeRing(r)).toBe(1);
}
});
it('rank 8 and beyond land on ring 2', () => {
expect(computeRing(RING_1_CAPACITY)).toBe(2);
expect(computeRing(15)).toBe(2);
expect(computeRing(99)).toBe(2);
});
});
describe('ringPositions (concentric circle layout)', () => {
it('returns an empty array for count 0', () => {
expect(ringPositions(0, 0, 0, 1)).toEqual([]);
});
it('places 4 nodes on ring 1 at radius RING_GAP, evenly spaced', () => {
const pts = ringPositions(0, 0, 4, 1, 0);
expect(pts).toHaveLength(4);
// First point at angle 0 → (RING_GAP, 0)
expect(pts[0].x).toBeCloseTo(RING_GAP, 6);
expect(pts[0].y).toBeCloseTo(0, 6);
// Every point sits on the circle of the correct radius.
for (const p of pts) {
const dist = Math.hypot(p.x, p.y);
expect(dist).toBeCloseTo(RING_GAP, 6);
}
});
it('places ring 2 at 2× RING_GAP from center', () => {
const pts = ringPositions(0, 0, 3, 2, 0);
for (const p of pts) {
expect(Math.hypot(p.x, p.y)).toBeCloseTo(RING_GAP * 2, 6);
}
});
it('honours the center (cx, cy)', () => {
const pts = ringPositions(500, 280, 2, 1, 0);
// With angleOffset=0 and 2 points, the two angles are 0 and π.
expect(pts[0].x).toBeCloseTo(500 + RING_GAP, 6);
expect(pts[0].y).toBeCloseTo(280, 6);
expect(pts[1].x).toBeCloseTo(500 - RING_GAP, 6);
expect(pts[1].y).toBeCloseTo(280, 6);
});
it('applies angleOffset to every point', () => {
const unrot = ringPositions(0, 0, 3, 1, 0);
const rot = ringPositions(0, 0, 3, 1, Math.PI / 2);
for (let i = 0; i < 3; i++) {
// Rotation preserves distance from center.
expect(Math.hypot(rot[i].x, rot[i].y)).toBeCloseTo(
Math.hypot(unrot[i].x, unrot[i].y),
6,
);
}
// And the first rotated point should now be near (0, RING_GAP) rather
// than (RING_GAP, 0).
expect(rot[0].x).toBeCloseTo(0, 6);
expect(rot[0].y).toBeCloseTo(RING_GAP, 6);
});
});
describe('layoutNeighbours (spills overflow to ring 2)', () => {
it('returns one point per neighbour', () => {
expect(layoutNeighbours(0, 0, 15, 0)).toHaveLength(15);
expect(layoutNeighbours(0, 0, 3, 0)).toHaveLength(3);
expect(layoutNeighbours(0, 0, 0, 0)).toHaveLength(0);
});
it('first 8 neighbours are on ring 1 (radius RING_GAP)', () => {
const pts = layoutNeighbours(0, 0, 15, 0);
for (let i = 0; i < RING_1_CAPACITY; i++) {
expect(Math.hypot(pts[i].x, pts[i].y)).toBeCloseTo(RING_GAP, 6);
}
});
it('neighbour 9..N are on ring 2 (radius 2*RING_GAP)', () => {
const pts = layoutNeighbours(0, 0, 15, 0);
for (let i = RING_1_CAPACITY; i < 15; i++) {
expect(Math.hypot(pts[i].x, pts[i].y)).toBeCloseTo(RING_GAP * 2, 6);
}
});
});
describe('initialActivation', () => {
it('rank 0 gets the highest activation', () => {
const a0 = initialActivation(0, 10);
const a1 = initialActivation(1, 10);
expect(a0).toBeGreaterThan(a1);
});
it('ring-2 ranks get a 0.75 ring penalty', () => {
// Rank 7 (last of ring 1) vs rank 8 (first of ring 2) — the jump in
// activation between them should include the 0.75 ring factor.
const ring1Last = initialActivation(7, 16);
const ring2First = initialActivation(8, 16);
expect(ring2First).toBeLessThan(ring1Last * 0.78);
});
it('returns values in (0, 1]', () => {
for (let i = 0; i < 20; i++) {
const a = initialActivation(i, 20);
expect(a).toBeGreaterThan(0);
expect(a).toBeLessThanOrEqual(1);
}
});
it('returns 0 for invalid inputs', () => {
expect(initialActivation(-1, 10)).toBe(0);
expect(initialActivation(0, 0)).toBe(0);
expect(initialActivation(Number.NaN, 10)).toBe(0);
});
});
// ---------------------------------------------------------------------------
// 5. Color mapping
// ---------------------------------------------------------------------------
describe('activationColor', () => {
it('source nodes always use SOURCE_COLOR (synapse-glow)', () => {
expect(activationColor('fact', true)).toBe(SOURCE_COLOR);
expect(activationColor('concept', true)).toBe(SOURCE_COLOR);
// Even if nodeType is garbage, source overrides.
expect(activationColor('garbage-type', true)).toBe(SOURCE_COLOR);
});
it('fact → NODE_TYPE_COLORS.fact (#00A8FF)', () => {
expect(activationColor('fact', false)).toBe(NODE_TYPE_COLORS.fact);
expect(activationColor('fact', false)).toBe('#00A8FF');
});
it('every known node type resolves to its palette entry', () => {
for (const type of Object.keys(NODE_TYPE_COLORS)) {
expect(activationColor(type, false)).toBe(NODE_TYPE_COLORS[type]);
}
});
it('unknown node type falls back to FALLBACK_COLOR (soft steel)', () => {
expect(activationColor('not-a-real-type', false)).toBe(FALLBACK_COLOR);
expect(FALLBACK_COLOR).toBe('#8B95A5');
});
it('null/undefined/empty nodeType also falls back', () => {
expect(activationColor(null, false)).toBe(FALLBACK_COLOR);
expect(activationColor(undefined, false)).toBe(FALLBACK_COLOR);
expect(activationColor('', false)).toBe(FALLBACK_COLOR);
});
});
// ---------------------------------------------------------------------------
// 6. Staggered edge delay
// ---------------------------------------------------------------------------
describe('edgeStagger', () => {
it('rank 0 has zero delay (first edge lights up immediately)', () => {
expect(edgeStagger(0)).toBe(0);
});
it('ring-1 edges are STAGGER_PER_RANK apart', () => {
expect(edgeStagger(1)).toBe(STAGGER_PER_RANK);
expect(edgeStagger(2)).toBe(STAGGER_PER_RANK * 2);
expect(edgeStagger(7)).toBe(STAGGER_PER_RANK * 7);
});
it('ring-2 edges add STAGGER_RING_2_BONUS on top of rank×stagger', () => {
expect(edgeStagger(8)).toBe(8 * STAGGER_PER_RANK + STAGGER_RING_2_BONUS);
expect(edgeStagger(12)).toBe(12 * STAGGER_PER_RANK + STAGGER_RING_2_BONUS);
});
it('monotonically non-decreasing', () => {
let prev = -1;
for (let i = 0; i < 20; i++) {
const s = edgeStagger(i);
expect(s).toBeGreaterThanOrEqual(prev);
prev = s;
}
});
it('produces 15 distinct delays for a typical 15-neighbour burst', () => {
const delays = Array.from({ length: 15 }, (_, i) => edgeStagger(i));
expect(new Set(delays).size).toBe(15);
});
});
// ---------------------------------------------------------------------------
// 7. Event-feed filter
// ---------------------------------------------------------------------------
function spreadEvent(
source_id: string,
target_ids: string[],
): VestigeEvent {
return { type: 'ActivationSpread', data: { source_id, target_ids } };
}
describe('filterNewSpreadEvents', () => {
it('returns [] on empty feed', () => {
expect(filterNewSpreadEvents([], null)).toEqual([]);
});
it('returns all ActivationSpread payloads when lastSeen is null', () => {
const feed = [
spreadEvent('a', ['b', 'c']),
spreadEvent('d', ['e']),
];
const out = filterNewSpreadEvents(feed, null);
expect(out).toHaveLength(2);
});
it('returns in oldest-first order (feed itself is newest-first)', () => {
const newest = spreadEvent('new', ['n1']);
const older = spreadEvent('old', ['o1']);
const out = filterNewSpreadEvents([newest, older], null);
expect(out[0].source_id).toBe('old');
expect(out[1].source_id).toBe('new');
});
it('stops at the lastSeen reference (object identity)', () => {
const oldest = spreadEvent('o', ['x']);
const middle = spreadEvent('m', ['y']);
const newest = spreadEvent('n', ['z']);
// Feed is prepended, so order is [newest, middle, oldest]
const feed = [newest, middle, oldest];
const out = filterNewSpreadEvents(feed, middle);
// Only `newest` is fresh — middle and oldest were already processed.
expect(out).toHaveLength(1);
expect(out[0].source_id).toBe('n');
});
it('returns [] if lastSeen is already the newest event', () => {
const e = spreadEvent('a', ['b']);
const out = filterNewSpreadEvents([e], e);
expect(out).toEqual([]);
});
it('ignores non-ActivationSpread events', () => {
const feed: VestigeEvent[] = [
{ type: 'MemoryCreated', data: { id: 'x' } },
spreadEvent('a', ['b']),
{ type: 'Heartbeat', data: {} },
];
const out = filterNewSpreadEvents(feed, null);
expect(out).toHaveLength(1);
expect(out[0].source_id).toBe('a');
});
it('skips malformed ActivationSpread events (missing / wrong-type fields)', () => {
const feed: VestigeEvent[] = [
{ type: 'ActivationSpread', data: {} }, // missing both
{ type: 'ActivationSpread', data: { source_id: 'a' } }, // no targets
{ type: 'ActivationSpread', data: { target_ids: ['b'] } }, // no source
{
type: 'ActivationSpread',
data: { source_id: 'a', target_ids: 'not-an-array' },
},
{
type: 'ActivationSpread',
data: { source_id: 'a', target_ids: [123, null, 'x'] },
},
];
const out = filterNewSpreadEvents(feed, null);
// Only the last one survives, with numeric/null targets filtered out.
expect(out).toHaveLength(1);
expect(out[0].source_id).toBe('a');
expect(out[0].target_ids).toEqual(['x']);
});
it('preserves target array contents faithfully', () => {
const feed = [spreadEvent('src', ['t1', 't2', 't3'])];
const out = filterNewSpreadEvents(feed, null);
expect(out[0].target_ids).toEqual(['t1', 't2', 't3']);
});
it('does not mutate its inputs', () => {
const feed = [spreadEvent('a', ['b', 'c'])];
const snapshot = JSON.stringify(feed);
filterNewSpreadEvents(feed, null);
expect(JSON.stringify(feed)).toBe(snapshot);
});
});
// ---------------------------------------------------------------------------
// Sanity: exported constants are the values the docstring promises
// ---------------------------------------------------------------------------
describe('exported constants (contract pinning)', () => {
it('RING_1_CAPACITY is 8', () => {
expect(RING_1_CAPACITY).toBe(8);
});
it('STAGGER_PER_RANK is 4 frames', () => {
expect(STAGGER_PER_RANK).toBe(4);
});
it('STAGGER_RING_2_BONUS is 12 frames', () => {
expect(STAGGER_RING_2_BONUS).toBe(12);
});
it('RING_GAP is 140px', () => {
expect(RING_GAP).toBe(140);
});
it('SOURCE_COLOR is synapse-glow #818cf8', () => {
expect(SOURCE_COLOR).toBe('#818cf8');
});
});

View file

@ -0,0 +1,439 @@
import { describe, it, expect } from 'vitest';
import {
ACTIVITY_BUCKET_COUNT,
ACTIVITY_BUCKET_MS,
ACTIVITY_WINDOW_MS,
bucketizeActivity,
dreamInsightsCount,
findRecentDream,
formatAgo,
hasRecentSuppression,
isDreaming,
parseEventTimestamp,
type EventLike,
} from '../awareness-helpers';
// Fixed "now" — March 1 2026 12:00:00 UTC. All tests are clock-free.
const NOW = Date.parse('2026-03-01T12:00:00.000Z');
function mkEvent(
type: string,
data: Record<string, unknown> = {},
): EventLike {
return { type, data };
}
// ─────────────────────────────────────────────────────────────────────────
// parseEventTimestamp
// ─────────────────────────────────────────────────────────────────────────
describe('parseEventTimestamp', () => {
it('parses ISO-8601 string', () => {
const e = mkEvent('Foo', { timestamp: '2026-03-01T12:00:00.000Z' });
expect(parseEventTimestamp(e)).toBe(NOW);
});
it('parses numeric ms (> 1e12)', () => {
const e = mkEvent('Foo', { timestamp: NOW });
expect(parseEventTimestamp(e)).toBe(NOW);
});
it('parses numeric seconds (<= 1e12) by scaling x1000', () => {
const secs = Math.floor(NOW / 1000);
const e = mkEvent('Foo', { timestamp: secs });
// Allow floating precision — must land in same second
const result = parseEventTimestamp(e);
expect(result).not.toBeNull();
expect(Math.abs((result as number) - NOW)).toBeLessThan(1000);
});
it('falls back to `at` field', () => {
const e = mkEvent('Foo', { at: '2026-03-01T12:00:00.000Z' });
expect(parseEventTimestamp(e)).toBe(NOW);
});
it('falls back to `occurred_at` field', () => {
const e = mkEvent('Foo', { occurred_at: '2026-03-01T12:00:00.000Z' });
expect(parseEventTimestamp(e)).toBe(NOW);
});
it('prefers `timestamp` over `at` over `occurred_at`', () => {
const e = mkEvent('Foo', {
timestamp: '2026-03-01T12:00:00.000Z',
at: '2020-01-01T00:00:00.000Z',
occurred_at: '2019-01-01T00:00:00.000Z',
});
expect(parseEventTimestamp(e)).toBe(NOW);
});
it('returns null for missing data', () => {
expect(parseEventTimestamp({ type: 'Foo' })).toBeNull();
});
it('returns null for empty data object', () => {
expect(parseEventTimestamp(mkEvent('Foo', {}))).toBeNull();
});
it('returns null for bad ISO string', () => {
expect(parseEventTimestamp(mkEvent('Foo', { timestamp: 'not-a-date' }))).toBeNull();
});
it('returns null for non-finite number (NaN)', () => {
expect(parseEventTimestamp(mkEvent('Foo', { timestamp: Number.NaN }))).toBeNull();
});
it('returns null for non-finite number (Infinity)', () => {
expect(parseEventTimestamp(mkEvent('Foo', { timestamp: Number.POSITIVE_INFINITY }))).toBeNull();
});
it('returns null for null timestamp', () => {
expect(parseEventTimestamp(mkEvent('Foo', { timestamp: null as unknown as string }))).toBeNull();
});
it('returns null for non-string non-number timestamp (object)', () => {
expect(parseEventTimestamp(mkEvent('Foo', { timestamp: {} as unknown as string }))).toBeNull();
});
it('returns null for a boolean timestamp', () => {
expect(parseEventTimestamp(mkEvent('Foo', { timestamp: true as unknown as string }))).toBeNull();
});
});
// ─────────────────────────────────────────────────────────────────────────
// bucketizeActivity
// ─────────────────────────────────────────────────────────────────────────
describe('bucketizeActivity', () => {
it('returns 10 buckets of 30s each covering a 5-min window', () => {
expect(ACTIVITY_BUCKET_COUNT).toBe(10);
expect(ACTIVITY_BUCKET_MS).toBe(30_000);
expect(ACTIVITY_WINDOW_MS).toBe(300_000);
const result = bucketizeActivity([], NOW);
expect(result).toHaveLength(10);
expect(result.every((b) => b.count === 0 && b.ratio === 0)).toBe(true);
});
it('assigns newest event to the last bucket (index 9)', () => {
const e = mkEvent('MemoryCreated', { timestamp: NOW - 100 });
const result = bucketizeActivity([e], NOW);
expect(result[9].count).toBe(1);
expect(result[9].ratio).toBe(1);
for (let i = 0; i < 9; i++) expect(result[i].count).toBe(0);
});
it('assigns oldest-edge event to bucket 0', () => {
// Exactly 5 min ago → at start boundary → floor((0)/30s) = 0
const e = mkEvent('MemoryCreated', { timestamp: NOW - ACTIVITY_WINDOW_MS + 1 });
const result = bucketizeActivity([e], NOW);
expect(result[0].count).toBe(1);
});
it('drops events older than 5 min (clock skew / pre-history)', () => {
const e = mkEvent('MemoryCreated', { timestamp: NOW - ACTIVITY_WINDOW_MS - 1 });
const result = bucketizeActivity([e], NOW);
expect(result.every((b) => b.count === 0)).toBe(true);
});
it('drops future events (negative clock skew)', () => {
const e = mkEvent('MemoryCreated', { timestamp: NOW + 5_000 });
const result = bucketizeActivity([e], NOW);
expect(result.every((b) => b.count === 0)).toBe(true);
});
it('drops Heartbeat events as noise', () => {
const e = mkEvent('Heartbeat', { timestamp: NOW - 100 });
const result = bucketizeActivity([e], NOW);
expect(result.every((b) => b.count === 0)).toBe(true);
});
it('drops events with unparseable timestamps', () => {
const e = mkEvent('MemoryCreated', { timestamp: 'garbage' });
const result = bucketizeActivity([e], NOW);
expect(result.every((b) => b.count === 0)).toBe(true);
});
it('distributes events across buckets and computes correct ratios', () => {
const events = [
// Bucket 9 (newest 30s): 3 events
mkEvent('MemoryCreated', { timestamp: NOW - 5_000 }),
mkEvent('MemoryCreated', { timestamp: NOW - 10_000 }),
mkEvent('MemoryCreated', { timestamp: NOW - 15_000 }),
// Bucket 8: 1 event (31s - 60s ago)
mkEvent('MemoryCreated', { timestamp: NOW - 35_000 }),
// Bucket 0 (oldest): 1 event (270s - 300s ago)
mkEvent('MemoryCreated', { timestamp: NOW - 290_000 }),
];
const result = bucketizeActivity(events, NOW);
expect(result[9].count).toBe(3);
expect(result[8].count).toBe(1);
expect(result[0].count).toBe(1);
expect(result[9].ratio).toBe(1);
expect(result[8].ratio).toBeCloseTo(1 / 3, 5);
expect(result[0].ratio).toBeCloseTo(1 / 3, 5);
});
it('handles events with numeric ms timestamp', () => {
const e = { type: 'MemoryCreated', data: { timestamp: NOW - 10_000 } };
const result = bucketizeActivity([e], NOW);
expect(result[9].count).toBe(1);
});
it('works with a mixed real-world feed (200 events, some stale)', () => {
const events: EventLike[] = [];
for (let i = 0; i < 200; i++) {
const offset = i * 3_000; // one every 3s, oldest first
events.unshift(mkEvent('MemoryCreated', { timestamp: NOW - offset }));
}
// add 10 Heartbeats mid-stream
for (let i = 0; i < 10; i++) {
events.push(mkEvent('Heartbeat', { timestamp: NOW - i * 1_000 }));
}
const result = bucketizeActivity(events, NOW);
// 101 events fit in the [now-300s, now] window: offsets 0, 3s, 6s, …, 300s.
// Heartbeats excluded. Sum should be exactly 101.
const total = result.reduce((s, b) => s + b.count, 0);
expect(total).toBe(101);
});
});
// ─────────────────────────────────────────────────────────────────────────
// findRecentDream
// ─────────────────────────────────────────────────────────────────────────
describe('findRecentDream', () => {
it('returns null on empty feed', () => {
expect(findRecentDream([], NOW)).toBeNull();
});
it('returns null when no DreamCompleted in feed', () => {
const feed = [
mkEvent('MemoryCreated', { timestamp: NOW - 1000 }),
mkEvent('DreamStarted', { timestamp: NOW - 500 }),
];
expect(findRecentDream(feed, NOW)).toBeNull();
});
it('returns the newest DreamCompleted within 24h', () => {
const fresh = mkEvent('DreamCompleted', {
timestamp: NOW - 60_000,
insights_generated: 7,
});
const stale = mkEvent('DreamCompleted', {
timestamp: NOW - 2 * 24 * 60 * 60 * 1000,
});
// Feed is newest-first
const result = findRecentDream([fresh, stale], NOW);
expect(result).toBe(fresh);
});
it('returns null when only DreamCompleted is older than 24h', () => {
const stale = mkEvent('DreamCompleted', {
timestamp: NOW - 25 * 60 * 60 * 1000,
});
expect(findRecentDream([stale], NOW)).toBeNull();
});
it('exactly 24h ago still counts (inclusive)', () => {
const edge = mkEvent('DreamCompleted', {
timestamp: NOW - 24 * 60 * 60 * 1000,
});
expect(findRecentDream([edge], NOW)).toBe(edge);
});
it('stops at first DreamCompleted in newest-first feed', () => {
const newest = mkEvent('DreamCompleted', { timestamp: NOW - 1_000 });
const older = mkEvent('DreamCompleted', { timestamp: NOW - 60_000 });
expect(findRecentDream([newest, older], NOW)).toBe(newest);
});
it('falls back to nowMs for unparseable timestamps (treated as recent)', () => {
const e = mkEvent('DreamCompleted', { timestamp: 'bad' });
expect(findRecentDream([e], NOW)).toBe(e);
});
});
// ─────────────────────────────────────────────────────────────────────────
// dreamInsightsCount
// ─────────────────────────────────────────────────────────────────────────
describe('dreamInsightsCount', () => {
it('returns null for null input', () => {
expect(dreamInsightsCount(null)).toBeNull();
});
it('returns null when missing', () => {
expect(dreamInsightsCount(mkEvent('DreamCompleted', {}))).toBeNull();
});
it('reads insights_generated (snake_case)', () => {
expect(
dreamInsightsCount(mkEvent('DreamCompleted', { insights_generated: 5 })),
).toBe(5);
});
it('reads insightsGenerated (camelCase)', () => {
expect(
dreamInsightsCount(mkEvent('DreamCompleted', { insightsGenerated: 3 })),
).toBe(3);
});
it('prefers snake_case when both present', () => {
expect(
dreamInsightsCount(
mkEvent('DreamCompleted', { insights_generated: 7, insightsGenerated: 99 }),
),
).toBe(7);
});
it('returns null for non-numeric value', () => {
expect(
dreamInsightsCount(mkEvent('DreamCompleted', { insights_generated: 'seven' as unknown as number })),
).toBeNull();
});
});
// ─────────────────────────────────────────────────────────────────────────
// isDreaming
// ─────────────────────────────────────────────────────────────────────────
describe('isDreaming', () => {
it('returns false for empty feed', () => {
expect(isDreaming([], NOW)).toBe(false);
});
it('returns false when no DreamStarted in feed', () => {
expect(isDreaming([mkEvent('MemoryCreated', { timestamp: NOW })], NOW)).toBe(false);
});
it('returns true for DreamStarted in last 5 min with no DreamCompleted', () => {
const feed = [mkEvent('DreamStarted', { timestamp: NOW - 60_000 })];
expect(isDreaming(feed, NOW)).toBe(true);
});
it('returns false for DreamStarted older than 5 min with no DreamCompleted', () => {
const feed = [mkEvent('DreamStarted', { timestamp: NOW - 6 * 60 * 1000 })];
expect(isDreaming(feed, NOW)).toBe(false);
});
it('returns false when DreamCompleted newer than DreamStarted', () => {
// Feed is newest-first: completed, then started
const feed = [
mkEvent('DreamCompleted', { timestamp: NOW - 30_000 }),
mkEvent('DreamStarted', { timestamp: NOW - 60_000 }),
];
expect(isDreaming(feed, NOW)).toBe(false);
});
it('returns true when DreamCompleted is OLDER than DreamStarted (new cycle began)', () => {
// Newest-first: started is newer, and there's an older completed from a prior cycle
const feed = [
mkEvent('DreamStarted', { timestamp: NOW - 30_000 }),
mkEvent('DreamCompleted', { timestamp: NOW - 10 * 60 * 1000 }),
];
expect(isDreaming(feed, NOW)).toBe(true);
});
it('boundary: DreamStarted exactly 5 min ago → still dreaming (>= check)', () => {
const feed = [mkEvent('DreamStarted', { timestamp: NOW - 5 * 60 * 1000 })];
expect(isDreaming(feed, NOW)).toBe(true);
});
it('only considers FIRST DreamStarted / FIRST DreamCompleted (newest-first semantics)', () => {
const feed = [
mkEvent('DreamStarted', { timestamp: NOW - 10_000 }),
mkEvent('DreamCompleted', { timestamp: NOW - 20_000 }), // older — prior cycle
mkEvent('DreamStarted', { timestamp: NOW - 30_000 }), // ignored
];
expect(isDreaming(feed, NOW)).toBe(true);
});
it('unparseable DreamStarted timestamp falls back to nowMs (counts as dreaming)', () => {
const feed = [mkEvent('DreamStarted', { timestamp: 'bad' })];
expect(isDreaming(feed, NOW)).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────
// hasRecentSuppression
// ─────────────────────────────────────────────────────────────────────────
describe('hasRecentSuppression', () => {
it('returns false for empty feed', () => {
expect(hasRecentSuppression([], NOW)).toBe(false);
});
it('returns false when no MemorySuppressed in feed', () => {
const feed = [
mkEvent('MemoryCreated', { timestamp: NOW }),
mkEvent('DreamStarted', { timestamp: NOW }),
];
expect(hasRecentSuppression(feed, NOW)).toBe(false);
});
it('returns true for MemorySuppressed within 10s', () => {
const feed = [mkEvent('MemorySuppressed', { timestamp: NOW - 5_000 })];
expect(hasRecentSuppression(feed, NOW)).toBe(true);
});
it('returns false for MemorySuppressed older than 10s', () => {
const feed = [mkEvent('MemorySuppressed', { timestamp: NOW - 11_000 })];
expect(hasRecentSuppression(feed, NOW)).toBe(false);
});
it('respects custom threshold', () => {
const feed = [mkEvent('MemorySuppressed', { timestamp: NOW - 8_000 })];
expect(hasRecentSuppression(feed, NOW, 5_000)).toBe(false);
expect(hasRecentSuppression(feed, NOW, 10_000)).toBe(true);
});
it('stops at first MemorySuppressed (newest-first short-circuit)', () => {
const feed = [
mkEvent('MemorySuppressed', { timestamp: NOW - 30_000 }), // first, outside window
mkEvent('MemorySuppressed', { timestamp: NOW - 1_000 }), // inside, but never checked
];
expect(hasRecentSuppression(feed, NOW)).toBe(false);
});
it('boundary: exactly at threshold counts (>= check)', () => {
const feed = [mkEvent('MemorySuppressed', { timestamp: NOW - 10_000 })];
expect(hasRecentSuppression(feed, NOW, 10_000)).toBe(true);
});
it('unparseable timestamp falls back to nowMs (flash fires)', () => {
const feed = [mkEvent('MemorySuppressed', { timestamp: 'bad' })];
expect(hasRecentSuppression(feed, NOW)).toBe(true);
});
it('ignores non-MemorySuppressed events before finding one', () => {
const feed = [
mkEvent('MemoryCreated', { timestamp: NOW }),
mkEvent('DreamStarted', { timestamp: NOW }),
mkEvent('MemorySuppressed', { timestamp: NOW - 3_000 }),
];
expect(hasRecentSuppression(feed, NOW)).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────
// formatAgo
// ─────────────────────────────────────────────────────────────────────────
describe('formatAgo', () => {
it('formats seconds', () => {
expect(formatAgo(5_000)).toBe('5s ago');
expect(formatAgo(59_000)).toBe('59s ago');
expect(formatAgo(0)).toBe('0s ago');
});
it('formats minutes', () => {
expect(formatAgo(60_000)).toBe('1m ago');
expect(formatAgo(59 * 60_000)).toBe('59m ago');
});
it('formats hours', () => {
expect(formatAgo(60 * 60_000)).toBe('1h ago');
expect(formatAgo(23 * 60 * 60_000)).toBe('23h ago');
});
it('formats days', () => {
expect(formatAgo(24 * 60 * 60_000)).toBe('1d ago');
expect(formatAgo(7 * 24 * 60 * 60_000)).toBe('7d ago');
});
it('clamps negative input to 0', () => {
expect(formatAgo(-5_000)).toBe('0s ago');
});
});

View file

@ -0,0 +1,326 @@
/**
* Contradiction Constellation pure-helper coverage.
*
* Runs in the vitest `node` environment (no jsdom). We only test the pure
* helpers extracted to `contradiction-helpers.ts`; the Svelte component is
* covered indirectly because every classification, opacity, radius, and
* color decision it renders routes through these functions.
*/
import { describe, it, expect } from 'vitest';
import {
severityColor,
severityLabel,
nodeColor,
nodeRadius,
clampTrust,
pairOpacity,
truncate,
uniqueMemoryCount,
avgTrustDelta,
NODE_COLORS,
KNOWN_NODE_TYPES,
NODE_COLOR_FALLBACK,
NODE_RADIUS_MIN,
NODE_RADIUS_RANGE,
SEVERITY_STRONG_COLOR,
SEVERITY_MODERATE_COLOR,
SEVERITY_MILD_COLOR,
UNFOCUSED_OPACITY,
type ContradictionLike,
} from '../contradiction-helpers';
// ---------------------------------------------------------------------------
// severityColor — strict-greater-than thresholds at 0.5 and 0.7.
// ---------------------------------------------------------------------------
describe('severityColor', () => {
it('returns mild yellow at or below 0.5', () => {
expect(severityColor(0)).toBe(SEVERITY_MILD_COLOR);
expect(severityColor(0.29)).toBe(SEVERITY_MILD_COLOR);
expect(severityColor(0.3)).toBe(SEVERITY_MILD_COLOR);
expect(severityColor(0.5)).toBe(SEVERITY_MILD_COLOR); // boundary → lower band
});
it('returns moderate amber strictly above 0.5 and up to 0.7', () => {
expect(severityColor(0.51)).toBe(SEVERITY_MODERATE_COLOR);
expect(severityColor(0.6)).toBe(SEVERITY_MODERATE_COLOR);
expect(severityColor(0.7)).toBe(SEVERITY_MODERATE_COLOR); // boundary → lower band
});
it('returns strong red strictly above 0.7', () => {
expect(severityColor(0.71)).toBe(SEVERITY_STRONG_COLOR);
expect(severityColor(0.9)).toBe(SEVERITY_STRONG_COLOR);
expect(severityColor(1.0)).toBe(SEVERITY_STRONG_COLOR);
});
it('handles out-of-range numbers without crashing', () => {
expect(severityColor(-1)).toBe(SEVERITY_MILD_COLOR);
expect(severityColor(1.5)).toBe(SEVERITY_STRONG_COLOR);
});
});
// ---------------------------------------------------------------------------
// severityLabel — matches severityColor thresholds.
// ---------------------------------------------------------------------------
describe('severityLabel', () => {
it('labels mild at 0, 0.29, 0.3, 0.5', () => {
expect(severityLabel(0)).toBe('mild');
expect(severityLabel(0.29)).toBe('mild');
expect(severityLabel(0.3)).toBe('mild');
expect(severityLabel(0.5)).toBe('mild');
});
it('labels moderate at 0.51, 0.7', () => {
expect(severityLabel(0.51)).toBe('moderate');
expect(severityLabel(0.7)).toBe('moderate');
});
it('labels strong at 0.71, 1.0', () => {
expect(severityLabel(0.71)).toBe('strong');
expect(severityLabel(1.0)).toBe('strong');
});
it('covers all 8 ordered boundary cases from the audit', () => {
expect(severityLabel(0)).toBe('mild');
expect(severityLabel(0.29)).toBe('mild');
expect(severityLabel(0.3)).toBe('mild');
expect(severityLabel(0.5)).toBe('mild');
expect(severityLabel(0.51)).toBe('moderate');
expect(severityLabel(0.7)).toBe('moderate');
expect(severityLabel(0.71)).toBe('strong');
expect(severityLabel(1.0)).toBe('strong');
});
});
// ---------------------------------------------------------------------------
// nodeColor — 8 known types plus fallback.
// ---------------------------------------------------------------------------
describe('nodeColor', () => {
it('returns distinct colors for each of the 8 known node types', () => {
const colors = KNOWN_NODE_TYPES.map((t) => nodeColor(t));
expect(colors.length).toBe(8);
expect(new Set(colors).size).toBe(8); // all distinct
});
it('matches the canonical palette exactly', () => {
expect(nodeColor('fact')).toBe(NODE_COLORS.fact);
expect(nodeColor('concept')).toBe(NODE_COLORS.concept);
expect(nodeColor('event')).toBe(NODE_COLORS.event);
expect(nodeColor('person')).toBe(NODE_COLORS.person);
expect(nodeColor('place')).toBe(NODE_COLORS.place);
expect(nodeColor('note')).toBe(NODE_COLORS.note);
expect(nodeColor('pattern')).toBe(NODE_COLORS.pattern);
expect(nodeColor('decision')).toBe(NODE_COLORS.decision);
});
it('falls back to violet for unknown / missing types', () => {
expect(nodeColor(undefined)).toBe(NODE_COLOR_FALLBACK);
expect(nodeColor(null)).toBe(NODE_COLOR_FALLBACK);
expect(nodeColor('')).toBe(NODE_COLOR_FALLBACK);
expect(nodeColor('bogus')).toBe(NODE_COLOR_FALLBACK);
expect(nodeColor('FACT')).toBe(NODE_COLOR_FALLBACK); // case-sensitive
});
it('violet fallback equals 0x8b5cf6', () => {
expect(NODE_COLOR_FALLBACK).toBe('#8b5cf6');
});
});
// ---------------------------------------------------------------------------
// nodeRadius + clampTrust — trust is defined on [0,1].
// ---------------------------------------------------------------------------
describe('nodeRadius', () => {
it('returns the minimum radius at trust=0', () => {
expect(nodeRadius(0)).toBe(NODE_RADIUS_MIN);
});
it('returns min + range at trust=1', () => {
expect(nodeRadius(1)).toBe(NODE_RADIUS_MIN + NODE_RADIUS_RANGE);
});
it('scales linearly in between', () => {
expect(nodeRadius(0.5)).toBeCloseTo(NODE_RADIUS_MIN + NODE_RADIUS_RANGE * 0.5);
});
it('clamps negative trust to 0 (minimum radius)', () => {
expect(nodeRadius(-0.5)).toBe(NODE_RADIUS_MIN);
expect(nodeRadius(-Infinity)).toBe(NODE_RADIUS_MIN);
});
it('clamps >1 trust to 1 (maximum radius)', () => {
expect(nodeRadius(2)).toBe(NODE_RADIUS_MIN + NODE_RADIUS_RANGE);
expect(nodeRadius(Infinity)).toBe(NODE_RADIUS_MIN);
// ^ Infinity isn't finite — falls back to min, matching "suppress suspicious data"
});
it('treats NaN as minimum (suppress bad data)', () => {
expect(nodeRadius(NaN)).toBe(NODE_RADIUS_MIN);
});
});
describe('clampTrust', () => {
it('returns values inside [0,1] unchanged', () => {
expect(clampTrust(0)).toBe(0);
expect(clampTrust(0.5)).toBe(0.5);
expect(clampTrust(1)).toBe(1);
});
it('clamps negatives to 0 and >1 to 1', () => {
expect(clampTrust(-0.3)).toBe(0);
expect(clampTrust(1.3)).toBe(1);
});
it('collapses NaN / null / undefined / Infinity to 0', () => {
expect(clampTrust(NaN)).toBe(0);
expect(clampTrust(null)).toBe(0);
expect(clampTrust(undefined)).toBe(0);
expect(clampTrust(Infinity)).toBe(0);
expect(clampTrust(-Infinity)).toBe(0);
});
});
// ---------------------------------------------------------------------------
// pairOpacity — trinary: no focus = 1, focused = 1, unfocused = 0.12.
// ---------------------------------------------------------------------------
describe('pairOpacity', () => {
it('returns 1 when no pair is focused (null)', () => {
expect(pairOpacity(0, null)).toBe(1);
expect(pairOpacity(5, null)).toBe(1);
});
it('returns 1 when no pair is focused (undefined)', () => {
expect(pairOpacity(0, undefined)).toBe(1);
expect(pairOpacity(5, undefined)).toBe(1);
});
it('returns 1 for the focused pair', () => {
expect(pairOpacity(3, 3)).toBe(1);
expect(pairOpacity(0, 0)).toBe(1);
});
it('returns 0.12 for a non-focused pair when something is focused', () => {
expect(pairOpacity(0, 3)).toBe(UNFOCUSED_OPACITY);
expect(pairOpacity(7, 3)).toBe(UNFOCUSED_OPACITY);
});
it('does not explode for a stale focus index that matches nothing', () => {
// A focus index of 999 with only 5 pairs: every visible pair dims to 0.12.
// The missing pair renders nothing (silent no-op is correct).
for (let i = 0; i < 5; i++) {
expect(pairOpacity(i, 999)).toBe(UNFOCUSED_OPACITY);
}
});
});
// ---------------------------------------------------------------------------
// truncate — length boundaries, empties, odd inputs.
// ---------------------------------------------------------------------------
describe('truncate', () => {
it('returns strings shorter than max unchanged', () => {
expect(truncate('hi', 10)).toBe('hi');
expect(truncate('abc', 5)).toBe('abc');
});
it('returns empty strings unchanged', () => {
expect(truncate('', 5)).toBe('');
expect(truncate('', 0)).toBe('');
});
it('returns strings exactly at max unchanged', () => {
expect(truncate('12345', 5)).toBe('12345');
expect(truncate('abcdef', 6)).toBe('abcdef');
});
it('cuts strings longer than max, appending ellipsis within budget', () => {
expect(truncate('1234567890', 5)).toBe('1234…');
expect(truncate('hello world', 6)).toBe('hello…');
});
it('uses default max of 60', () => {
const long = 'a'.repeat(100);
const out = truncate(long);
expect(out.length).toBe(60);
expect(out.endsWith('…')).toBe(true);
});
it('null / undefined inputs return empty string', () => {
expect(truncate(null)).toBe('');
expect(truncate(undefined)).toBe('');
});
it('handles max=0 safely', () => {
expect(truncate('any string', 0)).toBe('');
});
it('handles max=1 safely — one-char budget collapses to just the ellipsis', () => {
expect(truncate('abc', 1)).toBe('…');
});
});
// ---------------------------------------------------------------------------
// uniqueMemoryCount — union of memory_a_id + memory_b_id across pairs.
// ---------------------------------------------------------------------------
describe('uniqueMemoryCount', () => {
const mkPair = (a: string, b: string): ContradictionLike => ({
memory_a_id: a,
memory_b_id: b,
});
it('returns 0 for empty input', () => {
expect(uniqueMemoryCount([])).toBe(0);
});
it('counts both sides of every pair', () => {
expect(uniqueMemoryCount([mkPair('a', 'b')])).toBe(2);
expect(uniqueMemoryCount([mkPair('a', 'b'), mkPair('c', 'd')])).toBe(4);
});
it('deduplicates memories that appear in multiple pairs', () => {
// 'a' appears on both sides of two separate pairs.
expect(uniqueMemoryCount([mkPair('a', 'b'), mkPair('a', 'c')])).toBe(3);
expect(uniqueMemoryCount([mkPair('a', 'b'), mkPair('b', 'a')])).toBe(2);
});
it('handles a memory conflicting with itself (same id both sides)', () => {
expect(uniqueMemoryCount([mkPair('a', 'a')])).toBe(1);
});
it('ignores empty-string ids', () => {
expect(uniqueMemoryCount([mkPair('', '')])).toBe(0);
expect(uniqueMemoryCount([mkPair('a', '')])).toBe(1);
});
});
// ---------------------------------------------------------------------------
// avgTrustDelta — safety against empty inputs.
// ---------------------------------------------------------------------------
describe('avgTrustDelta', () => {
it('returns 0 on empty input (no NaN)', () => {
expect(avgTrustDelta([])).toBe(0);
});
it('computes mean absolute delta', () => {
const pairs = [
{ trust_a: 0.9, trust_b: 0.1 }, // 0.8
{ trust_a: 0.5, trust_b: 0.3 }, // 0.2
];
expect(avgTrustDelta(pairs)).toBeCloseTo(0.5);
});
it('takes absolute value (order does not matter)', () => {
expect(avgTrustDelta([{ trust_a: 0.1, trust_b: 0.9 }])).toBeCloseTo(0.8);
expect(avgTrustDelta([{ trust_a: 0.9, trust_b: 0.1 }])).toBeCloseTo(0.8);
});
it('returns 0 when both sides are equal', () => {
expect(avgTrustDelta([{ trust_a: 0.5, trust_b: 0.5 }])).toBe(0);
});
});

View file

@ -0,0 +1,258 @@
/**
* Tests for DreamInsightCard helpers.
*
* Pure logic only the Svelte template is a thin wrapper around these.
* Covers the boundaries of the gold-glow / muted novelty mapping, the
* formatting helpers, and the source-memory link scheme.
*/
import { describe, it, expect } from 'vitest';
import {
LOW_NOVELTY_THRESHOLD,
HIGH_NOVELTY_THRESHOLD,
clamp01,
noveltyBand,
formatDurationMs,
formatConfidencePct,
sourceMemoryHref,
firstSourceIds,
extraSourceCount,
shortMemoryId,
} from '../dream-helpers';
// ---------------------------------------------------------------------------
// clamp01
// ---------------------------------------------------------------------------
describe('clamp01', () => {
it.each<[number | null | undefined, number]>([
[0, 0],
[1, 1],
[0.5, 0.5],
[-0.1, 0],
[-5, 0],
[1.1, 1],
[100, 1],
[null, 0],
[undefined, 0],
[Number.NaN, 0],
[Number.POSITIVE_INFINITY, 0],
[Number.NEGATIVE_INFINITY, 0],
])('clamp01(%s) → %s', (input, expected) => {
expect(clamp01(input)).toBe(expected);
});
});
// ---------------------------------------------------------------------------
// noveltyBand — the gold/muted visual classifier
// ---------------------------------------------------------------------------
describe('noveltyBand — gold-glow / muted classification', () => {
it('has the documented thresholds', () => {
// These constants are contractual — the component's class bindings
// depend on them. If they change, the visual band shifts.
expect(LOW_NOVELTY_THRESHOLD).toBe(0.3);
expect(HIGH_NOVELTY_THRESHOLD).toBe(0.7);
});
it('classifies low-novelty (< 0.3) as muted', () => {
expect(noveltyBand(0)).toBe('low');
expect(noveltyBand(0.1)).toBe('low');
expect(noveltyBand(0.29)).toBe('low');
expect(noveltyBand(0.2999)).toBe('low');
});
it('classifies the boundary 0.3 exactly as neutral (NOT low)', () => {
// The component uses `novelty < 0.3`, strictly exclusive.
expect(noveltyBand(0.3)).toBe('neutral');
});
it('classifies mid-range as neutral', () => {
expect(noveltyBand(0.3)).toBe('neutral');
expect(noveltyBand(0.5)).toBe('neutral');
expect(noveltyBand(0.7)).toBe('neutral');
});
it('classifies the boundary 0.7 exactly as neutral (NOT high)', () => {
// The component uses `novelty > 0.7`, strictly exclusive.
expect(noveltyBand(0.7)).toBe('neutral');
});
it('classifies high-novelty (> 0.7) as gold/high', () => {
expect(noveltyBand(0.71)).toBe('high');
expect(noveltyBand(0.7001)).toBe('high');
expect(noveltyBand(0.9)).toBe('high');
expect(noveltyBand(1.0)).toBe('high');
});
it('collapses null / undefined / NaN to the low band', () => {
expect(noveltyBand(null)).toBe('low');
expect(noveltyBand(undefined)).toBe('low');
expect(noveltyBand(Number.NaN)).toBe('low');
});
it('clamps out-of-range values before classifying', () => {
// 2.0 clamps to 1.0 → high; -1 clamps to 0 → low.
expect(noveltyBand(2.0)).toBe('high');
expect(noveltyBand(-1)).toBe('low');
});
});
// ---------------------------------------------------------------------------
// formatDurationMs
// ---------------------------------------------------------------------------
describe('formatDurationMs', () => {
it('renders sub-second values with "ms" suffix', () => {
expect(formatDurationMs(0)).toBe('0ms');
expect(formatDurationMs(1)).toBe('1ms');
expect(formatDurationMs(500)).toBe('500ms');
expect(formatDurationMs(999)).toBe('999ms');
});
it('renders second-and-above values with "s" suffix, 2 decimals', () => {
expect(formatDurationMs(1000)).toBe('1.00s');
expect(formatDurationMs(1500)).toBe('1.50s');
expect(formatDurationMs(15000)).toBe('15.00s');
expect(formatDurationMs(60000)).toBe('60.00s');
});
it('rounds fractional millisecond values in the "ms" band', () => {
expect(formatDurationMs(0.4)).toBe('0ms');
expect(formatDurationMs(12.7)).toBe('13ms');
});
it('returns "0ms" for null / undefined / NaN / negative', () => {
expect(formatDurationMs(null)).toBe('0ms');
expect(formatDurationMs(undefined)).toBe('0ms');
expect(formatDurationMs(Number.NaN)).toBe('0ms');
expect(formatDurationMs(-100)).toBe('0ms');
expect(formatDurationMs(Number.POSITIVE_INFINITY)).toBe('0ms');
});
});
// ---------------------------------------------------------------------------
// formatConfidencePct
// ---------------------------------------------------------------------------
describe('formatConfidencePct', () => {
it('renders 0 / 0.5 / 1 as whole-percent strings', () => {
expect(formatConfidencePct(0)).toBe('0%');
expect(formatConfidencePct(0.5)).toBe('50%');
expect(formatConfidencePct(1)).toBe('100%');
});
it('rounds intermediate values', () => {
expect(formatConfidencePct(0.123)).toBe('12%');
expect(formatConfidencePct(0.5049)).toBe('50%');
expect(formatConfidencePct(0.505)).toBe('51%');
expect(formatConfidencePct(0.999)).toBe('100%');
});
it('clamps out-of-range input first', () => {
expect(formatConfidencePct(-0.5)).toBe('0%');
expect(formatConfidencePct(2)).toBe('100%');
});
it('handles null / undefined / NaN', () => {
expect(formatConfidencePct(null)).toBe('0%');
expect(formatConfidencePct(undefined)).toBe('0%');
expect(formatConfidencePct(Number.NaN)).toBe('0%');
});
});
// ---------------------------------------------------------------------------
// sourceMemoryHref
// ---------------------------------------------------------------------------
describe('sourceMemoryHref — link format', () => {
it('builds the canonical /memories/:id path with no base', () => {
expect(sourceMemoryHref('abc123')).toBe('/memories/abc123');
});
it('prepends the SvelteKit base path when provided', () => {
expect(sourceMemoryHref('abc123', '/dashboard')).toBe(
'/dashboard/memories/abc123',
);
});
it('handles an empty base (default behaviour)', () => {
expect(sourceMemoryHref('abc', '')).toBe('/memories/abc');
});
it('passes through full UUIDs untouched', () => {
const uuid = '550e8400-e29b-41d4-a716-446655440000';
expect(sourceMemoryHref(uuid)).toBe(`/memories/${uuid}`);
});
});
// ---------------------------------------------------------------------------
// firstSourceIds + extraSourceCount
// ---------------------------------------------------------------------------
describe('firstSourceIds', () => {
it('returns [] for empty / null / undefined inputs', () => {
expect(firstSourceIds([])).toEqual([]);
expect(firstSourceIds(null)).toEqual([]);
expect(firstSourceIds(undefined)).toEqual([]);
});
it('returns the single element when array has one entry', () => {
expect(firstSourceIds(['a'])).toEqual(['a']);
});
it('returns the first 2 by default', () => {
expect(firstSourceIds(['a', 'b', 'c', 'd'])).toEqual(['a', 'b']);
});
it('honours a custom N', () => {
expect(firstSourceIds(['a', 'b', 'c', 'd'], 3)).toEqual(['a', 'b', 'c']);
expect(firstSourceIds(['a', 'b', 'c'], 5)).toEqual(['a', 'b', 'c']);
});
it('returns [] for non-positive N', () => {
expect(firstSourceIds(['a', 'b'], 0)).toEqual([]);
expect(firstSourceIds(['a', 'b'], -1)).toEqual([]);
});
});
describe('extraSourceCount', () => {
it('returns 0 when there are no extras', () => {
expect(extraSourceCount([])).toBe(0);
expect(extraSourceCount(null)).toBe(0);
expect(extraSourceCount(['a'])).toBe(0);
expect(extraSourceCount(['a', 'b'])).toBe(0);
});
it('returns sources.length - shown when there are extras', () => {
expect(extraSourceCount(['a', 'b', 'c'])).toBe(1);
expect(extraSourceCount(['a', 'b', 'c', 'd', 'e'])).toBe(3);
});
it('honours a custom shown parameter', () => {
expect(extraSourceCount(['a', 'b', 'c', 'd', 'e'], 3)).toBe(2);
expect(extraSourceCount(['a', 'b'], 5)).toBe(0);
});
});
// ---------------------------------------------------------------------------
// shortMemoryId
// ---------------------------------------------------------------------------
describe('shortMemoryId', () => {
it('returns the full string when 8 chars or fewer', () => {
expect(shortMemoryId('abc')).toBe('abc');
expect(shortMemoryId('12345678')).toBe('12345678');
});
it('slices to 8 chars when longer', () => {
expect(shortMemoryId('123456789')).toBe('12345678');
expect(shortMemoryId('550e8400-e29b-41d4-a716-446655440000')).toBe(
'550e8400',
);
});
it('handles empty string defensively', () => {
expect(shortMemoryId('')).toBe('');
});
});

View file

@ -0,0 +1,104 @@
/**
* Tests for DreamStageReplay helpers.
*
* The Svelte component itself is rendered with CSS transforms + derived
* state. We can't mount it in Node without jsdom, so we test the PURE
* helpers it relies on the same helpers also power the page's scrubber
* and the insight card. If `clampStage` is green, the scrubber can't go
* out of range; if `STAGE_NAMES` stays in sync with MemoryDreamer's 5
* phases, the badge labels stay correct.
*/
import { describe, it, expect } from 'vitest';
import {
STAGE_COUNT,
STAGE_NAMES,
clampStage,
stageName,
} from '../dream-helpers';
describe('STAGE_NAMES — MemoryDreamer phase list', () => {
it('has exactly 5 stages matching MemoryDreamer.run()', () => {
expect(STAGE_COUNT).toBe(5);
expect(STAGE_NAMES).toHaveLength(5);
});
it('lists the phases in the canonical order', () => {
// Order is load-bearing: the stage replay animates in this sequence.
// Replay → Cross-reference → Strengthen → Prune → Transfer.
expect(STAGE_NAMES).toEqual([
'Replay',
'Cross-reference',
'Strengthen',
'Prune',
'Transfer',
]);
});
});
describe('clampStage — valid-range enforcement', () => {
it.each<[number, number]>([
// Out-of-bounds low
[0, 1],
[-1, 1],
[-100, 1],
// In-range (exactly the valid stage indices)
[1, 1],
[2, 2],
[3, 3],
[4, 4],
[5, 5],
// Out-of-bounds high
[6, 5],
[7, 5],
[100, 5],
])('clampStage(%s) → %s', (input, expected) => {
expect(clampStage(input)).toBe(expected);
});
it('floors fractional values before clamping', () => {
expect(clampStage(1.9)).toBe(1);
expect(clampStage(4.9)).toBe(4);
expect(clampStage(5.1)).toBe(5);
});
it('collapses NaN / Infinity / -Infinity to stage 1', () => {
expect(clampStage(Number.NaN)).toBe(1);
expect(clampStage(Number.POSITIVE_INFINITY)).toBe(1);
expect(clampStage(Number.NEGATIVE_INFINITY)).toBe(1);
});
it('returns a value usable as a 0-indexed STAGE_NAMES lookup', () => {
// The page uses `STAGE_NAMES[stageIdx - 1]`. Every clamped value
// must index a real name, not undefined.
for (const raw of [-5, 0, 1, 3, 5, 10, Number.NaN]) {
const idx = clampStage(raw);
expect(STAGE_NAMES[idx - 1]).toBeDefined();
expect(typeof STAGE_NAMES[idx - 1]).toBe('string');
}
});
});
describe('stageName — resolves to the visible label', () => {
it('returns the matching name for every valid stage', () => {
expect(stageName(1)).toBe('Replay');
expect(stageName(2)).toBe('Cross-reference');
expect(stageName(3)).toBe('Strengthen');
expect(stageName(4)).toBe('Prune');
expect(stageName(5)).toBe('Transfer');
});
it('falls back to the nearest valid name for out-of-range input', () => {
expect(stageName(0)).toBe('Replay');
expect(stageName(-1)).toBe('Replay');
expect(stageName(6)).toBe('Transfer');
expect(stageName(100)).toBe('Transfer');
});
it('never returns undefined, even for garbage input', () => {
for (const raw of [Number.NaN, Number.POSITIVE_INFINITY, -Number.MAX_VALUE]) {
expect(stageName(raw)).toBeDefined();
expect(stageName(raw)).toMatch(/^[A-Z]/);
}
});
});

View file

@ -0,0 +1,365 @@
/**
* Pure-logic tests for the Memory Hygiene / Duplicate Detection UI.
*
* The Svelte components themselves are render-level code (no jsdom in this
* repo) every ounce of behaviour worth testing is extracted into
* `duplicates-helpers.ts` and exercised here. If this file is green, the
* similarity bands, winner selection, suggested-action mapping, threshold
* filtering, cluster-identity keying, and the "safe render" helpers are all
* sound.
*/
import { describe, it, expect } from 'vitest';
import {
similarityBand,
similarityBandColor,
similarityBandLabel,
retentionColor,
pickWinner,
suggestedActionFor,
filterByThreshold,
clusterKey,
previewContent,
formatDate,
safeTags,
} from '../duplicates-helpers';
// ---------------------------------------------------------------------------
// Similarity band — boundaries at 0.92 (red) and 0.80 (amber).
// The boundary value MUST land in the higher band (>= semantics).
// ---------------------------------------------------------------------------
describe('similarityBand', () => {
it('0.92 exactly → near-identical (boundary)', () => {
expect(similarityBand(0.92)).toBe('near-identical');
});
it('0.91 → strong (just below upper boundary)', () => {
expect(similarityBand(0.91)).toBe('strong');
});
it('0.80 exactly → strong (boundary)', () => {
expect(similarityBand(0.8)).toBe('strong');
});
it('0.79 → weak (just below strong boundary)', () => {
expect(similarityBand(0.79)).toBe('weak');
});
it('0.50 → weak (well below)', () => {
expect(similarityBand(0.5)).toBe('weak');
});
it('1.0 → near-identical', () => {
expect(similarityBand(1.0)).toBe('near-identical');
});
it('0.0 → weak', () => {
expect(similarityBand(0.0)).toBe('weak');
});
});
describe('similarityBandColor', () => {
it('near-identical → decay var (red)', () => {
expect(similarityBandColor(0.95)).toBe('var(--color-decay)');
});
it('strong → warning var (amber)', () => {
expect(similarityBandColor(0.85)).toBe('var(--color-warning)');
});
it('weak → yellow-300 literal', () => {
expect(similarityBandColor(0.78)).toBe('#fde047');
});
it('is consistent at boundary 0.92', () => {
expect(similarityBandColor(0.92)).toBe('var(--color-decay)');
});
it('is consistent at boundary 0.80', () => {
expect(similarityBandColor(0.8)).toBe('var(--color-warning)');
});
});
describe('similarityBandLabel', () => {
it('labels near-identical', () => {
expect(similarityBandLabel(0.97)).toBe('Near-identical');
});
it('labels strong', () => {
expect(similarityBandLabel(0.85)).toBe('Strong match');
});
it('labels weak', () => {
expect(similarityBandLabel(0.75)).toBe('Weak match');
});
});
// ---------------------------------------------------------------------------
// Retention color — traffic-light: >0.7 green, >0.4 amber, else red.
// ---------------------------------------------------------------------------
describe('retentionColor', () => {
it('0.85 → green', () => expect(retentionColor(0.85)).toBe('#10b981'));
it('0.50 → amber', () => expect(retentionColor(0.5)).toBe('#f59e0b'));
it('0.30 → red', () => expect(retentionColor(0.3)).toBe('#ef4444'));
it('boundary 0.70 → amber (strict >)', () => expect(retentionColor(0.7)).toBe('#f59e0b'));
it('boundary 0.40 → red (strict >)', () => expect(retentionColor(0.4)).toBe('#ef4444'));
it('0.0 → red', () => expect(retentionColor(0)).toBe('#ef4444'));
});
// ---------------------------------------------------------------------------
// Winner selection — highest retention wins; ties → earliest index; empty
// list → null; NaN retentions never win.
// ---------------------------------------------------------------------------
describe('pickWinner', () => {
it('picks highest retention', () => {
const mem = [
{ id: 'a', retention: 0.3 },
{ id: 'b', retention: 0.9 },
{ id: 'c', retention: 0.5 },
];
expect(pickWinner(mem)?.id).toBe('b');
});
it('tie-break: earliest wins (stable)', () => {
const mem = [
{ id: 'a', retention: 0.8 },
{ id: 'b', retention: 0.8 },
{ id: 'c', retention: 0.7 },
];
expect(pickWinner(mem)?.id).toBe('a');
});
it('three-way tie: earliest wins', () => {
const mem = [
{ id: 'x', retention: 0.5 },
{ id: 'y', retention: 0.5 },
{ id: 'z', retention: 0.5 },
];
expect(pickWinner(mem)?.id).toBe('x');
});
it('all retention = 0: earliest wins (not null)', () => {
const mem = [
{ id: 'a', retention: 0 },
{ id: 'b', retention: 0 },
];
expect(pickWinner(mem)?.id).toBe('a');
});
it('single-member cluster: that member wins', () => {
const mem = [{ id: 'solo', retention: 0.42 }];
expect(pickWinner(mem)?.id).toBe('solo');
});
it('empty cluster: returns null', () => {
expect(pickWinner([])).toBeNull();
});
it('NaN retention never wins over a real one', () => {
const mem = [
{ id: 'nan', retention: Number.NaN },
{ id: 'real', retention: 0.1 },
];
expect(pickWinner(mem)?.id).toBe('real');
});
it('all NaN retentions: earliest wins (stable fallback)', () => {
const mem = [
{ id: 'a', retention: Number.NaN },
{ id: 'b', retention: Number.NaN },
];
expect(pickWinner(mem)?.id).toBe('a');
});
});
// ---------------------------------------------------------------------------
// Suggested action — >=0.92 merge, <0.85 review, 0.85..<0.92 null (caller
// honors upstream).
// ---------------------------------------------------------------------------
describe('suggestedActionFor', () => {
it('0.95 → merge', () => expect(suggestedActionFor(0.95)).toBe('merge'));
it('0.92 exactly → merge (boundary)', () => expect(suggestedActionFor(0.92)).toBe('merge'));
it('0.91 → null (ambiguous corridor)', () => expect(suggestedActionFor(0.91)).toBeNull());
it('0.85 exactly → null (corridor bottom boundary)', () =>
expect(suggestedActionFor(0.85)).toBeNull());
it('0.849 → review (just below corridor)', () =>
expect(suggestedActionFor(0.849)).toBe('review'));
it('0.70 → review', () => expect(suggestedActionFor(0.7)).toBe('review'));
it('0.0 → review', () => expect(suggestedActionFor(0)).toBe('review'));
it('1.0 → merge', () => expect(suggestedActionFor(1.0)).toBe('merge'));
});
// ---------------------------------------------------------------------------
// Threshold filter — strict >=.
// ---------------------------------------------------------------------------
describe('filterByThreshold', () => {
const clusters = [
{ similarity: 0.96, memories: [{ id: '1', retention: 1 }] },
{ similarity: 0.88, memories: [{ id: '2', retention: 1 }] },
{ similarity: 0.78, memories: [{ id: '3', retention: 1 }] },
];
it('0.80 keeps 0.96 and 0.88 (drops 0.78)', () => {
const out = filterByThreshold(clusters, 0.8);
expect(out.map((c) => c.similarity)).toEqual([0.96, 0.88]);
});
it('boundary: threshold = 0.88 keeps 0.88 (>=)', () => {
const out = filterByThreshold(clusters, 0.88);
expect(out.map((c) => c.similarity)).toEqual([0.96, 0.88]);
});
it('boundary: threshold = 0.881 drops 0.88', () => {
const out = filterByThreshold(clusters, 0.881);
expect(out.map((c) => c.similarity)).toEqual([0.96]);
});
it('0.95 (max) keeps only 0.96', () => {
const out = filterByThreshold(clusters, 0.95);
expect(out.map((c) => c.similarity)).toEqual([0.96]);
});
it('0.70 (min) keeps all three', () => {
const out = filterByThreshold(clusters, 0.7);
expect(out).toHaveLength(3);
});
it('empty input → empty output', () => {
expect(filterByThreshold([], 0.8)).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// Cluster identity — stable across order shuffles and re-fetches.
// ---------------------------------------------------------------------------
describe('clusterKey', () => {
it('identical member sets → identical keys (order-independent)', () => {
const a = [
{ id: 'a', retention: 0 },
{ id: 'b', retention: 0 },
{ id: 'c', retention: 0 },
];
const b = [
{ id: 'c', retention: 0 },
{ id: 'a', retention: 0 },
{ id: 'b', retention: 0 },
];
expect(clusterKey(a)).toBe(clusterKey(b));
});
it('differing members → differing keys', () => {
const a = [
{ id: 'a', retention: 0 },
{ id: 'b', retention: 0 },
];
const b = [
{ id: 'a', retention: 0 },
{ id: 'c', retention: 0 },
];
expect(clusterKey(a)).not.toBe(clusterKey(b));
});
it('does not mutate input order', () => {
const mem = [
{ id: 'z', retention: 0 },
{ id: 'a', retention: 0 },
];
clusterKey(mem);
expect(mem.map((m) => m.id)).toEqual(['z', 'a']);
});
it('empty cluster → empty string', () => {
expect(clusterKey([])).toBe('');
});
});
// ---------------------------------------------------------------------------
// previewContent — trim + collapse whitespace + truncate at 80.
// ---------------------------------------------------------------------------
describe('previewContent', () => {
it('short content: unchanged', () => {
expect(previewContent('hello world')).toBe('hello world');
});
it('collapses internal whitespace', () => {
expect(previewContent(' hello world ')).toBe('hello world');
});
it('truncates with ellipsis', () => {
const long = 'a'.repeat(120);
const out = previewContent(long);
expect(out.length).toBe(81); // 80 + ellipsis
expect(out.endsWith('…')).toBe(true);
});
it('null-safe', () => {
expect(previewContent(null)).toBe('');
expect(previewContent(undefined)).toBe('');
});
it('honors custom max', () => {
expect(previewContent('abcdefghij', 5)).toBe('abcde…');
});
});
// ---------------------------------------------------------------------------
// formatDate — valid ISO → formatted; everything else → empty.
// ---------------------------------------------------------------------------
describe('formatDate', () => {
it('valid ISO → non-empty formatted string', () => {
const out = formatDate('2026-04-14T11:02:00Z');
expect(out.length).toBeGreaterThan(0);
expect(out).not.toBe('Invalid Date');
});
it('empty string → empty', () => {
expect(formatDate('')).toBe('');
});
it('null → empty', () => {
expect(formatDate(null)).toBe('');
});
it('undefined → empty', () => {
expect(formatDate(undefined)).toBe('');
});
it('garbage string → empty (no "Invalid Date" leak)', () => {
expect(formatDate('not-a-date')).toBe('');
});
it('non-string input → empty (defensive)', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(formatDate(12345 as any)).toBe('');
});
});
// ---------------------------------------------------------------------------
// safeTags — tolerant of undefined / non-array / empty.
// ---------------------------------------------------------------------------
describe('safeTags', () => {
it('normal array: slices to limit', () => {
expect(safeTags(['a', 'b', 'c', 'd', 'e'], 3)).toEqual(['a', 'b', 'c']);
});
it('undefined → []', () => {
expect(safeTags(undefined)).toEqual([]);
});
it('null → []', () => {
expect(safeTags(null)).toEqual([]);
});
it('empty array → []', () => {
expect(safeTags([])).toEqual([]);
});
it('non-array (defensive) → []', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(safeTags('bad' as any)).toEqual([]);
});
it('honors default limit = 4', () => {
expect(safeTags(['a', 'b', 'c', 'd', 'e', 'f'])).toEqual(['a', 'b', 'c', 'd']);
});
});

View file

@ -0,0 +1,255 @@
/**
* EvidenceCard pure-logic coverage.
*
* The component itself mounts Svelte, which vitest cannot do in a node
* environment. Every piece of logic that was reachable via props has been
* extracted to `reasoning-helpers.ts`; this file exhaustively exercises
* those helpers through the same import surface EvidenceCard uses. If
* this file is green, the card's visual output is a 1:1 function of the
* helper output.
*/
import { describe, it, expect } from 'vitest';
import {
ROLE_META,
roleMetaFor,
trustColor,
trustPercent,
clampTrust,
nodeTypeColor,
formatDate,
shortenId,
CONFIDENCE_EMERALD,
CONFIDENCE_AMBER,
CONFIDENCE_RED,
DEFAULT_NODE_TYPE_COLOR,
type EvidenceRole,
} from '../reasoning-helpers';
import { NODE_TYPE_COLORS } from '$types';
// ────────────────────────────────────────────────────────────────
// clampTrust + trustPercent — numeric contract
// ────────────────────────────────────────────────────────────────
describe('clampTrust — 0-1 display range', () => {
it.each<[number, number]>([
[0, 0],
[0.5, 0.5],
[1, 1],
[-0.1, 0],
[-1, 0],
[1.2, 1],
[999, 1],
])('clamps %f → %f', (input, expected) => {
expect(clampTrust(input)).toBe(expected);
});
it('returns 0 for NaN (defensive — avoids NaN% in the UI)', () => {
expect(clampTrust(Number.NaN)).toBe(0);
});
it('returns 0 for non-finite inputs (+/-Infinity) — safe default', () => {
// Infinity indicates upstream garbage — degrade to empty bar rather
// than saturate the UI to 100%.
expect(clampTrust(-Infinity)).toBe(0);
expect(clampTrust(Infinity)).toBe(0);
});
it('is idempotent (clamp of clamp is the same)', () => {
for (const v of [-0.5, 0, 0.3, 0.75, 1, 2]) {
expect(clampTrust(clampTrust(v))).toBe(clampTrust(v));
}
});
});
describe('trustPercent — 0-100 rendering', () => {
it.each<[number, number]>([
[0, 0],
[0.5, 50],
[1, 100],
[-0.1, 0],
[1.2, 100],
])('converts trust %f → %f%%', (t, expected) => {
expect(trustPercent(t)).toBe(expected);
});
it('handles NaN without producing NaN', () => {
expect(trustPercent(Number.NaN)).toBe(0);
});
});
// ────────────────────────────────────────────────────────────────
// trustColor — band boundaries for the card's trust bar
// ────────────────────────────────────────────────────────────────
describe('trustColor — boundary analysis', () => {
it.each<[number, string]>([
// Emerald band: strictly > 0.75 → > 75%
[1.0, CONFIDENCE_EMERALD],
[0.9, CONFIDENCE_EMERALD],
[0.751, CONFIDENCE_EMERALD],
// Amber band: 0.40 ≤ t ≤ 0.75
[0.75, CONFIDENCE_AMBER], // boundary — amber at exactly 75%
[0.5, CONFIDENCE_AMBER],
[0.4, CONFIDENCE_AMBER], // boundary — amber at exactly 40%
// Red band: < 0.40
[0.399, CONFIDENCE_RED],
[0.2, CONFIDENCE_RED],
[0, CONFIDENCE_RED],
])('trust %f → %s', (t, expected) => {
expect(trustColor(t)).toBe(expected);
});
it('clamps negative to red and super-high to emerald (defensive)', () => {
expect(trustColor(-0.5)).toBe(CONFIDENCE_RED);
expect(trustColor(1.5)).toBe(CONFIDENCE_EMERALD);
});
it('returns red for NaN (lowest-confidence fallback)', () => {
expect(trustColor(Number.NaN)).toBe(CONFIDENCE_RED);
});
});
// ────────────────────────────────────────────────────────────────
// Role metadata — label + accent + icon
// ────────────────────────────────────────────────────────────────
describe('ROLE_META — completeness and shape', () => {
const roles: EvidenceRole[] = ['primary', 'supporting', 'contradicting', 'superseded'];
it('defines an entry for every role', () => {
for (const r of roles) {
expect(ROLE_META[r]).toBeDefined();
}
});
it.each(roles)('%s has non-empty label + icon', (r) => {
const meta = ROLE_META[r];
expect(meta.label.length).toBeGreaterThan(0);
expect(meta.icon.length).toBeGreaterThan(0);
});
it('maps to the expected accent tokens used by Tailwind (synapse/recall/decay/muted)', () => {
expect(ROLE_META.primary.accent).toBe('synapse');
expect(ROLE_META.supporting.accent).toBe('recall');
expect(ROLE_META.contradicting.accent).toBe('decay');
expect(ROLE_META.superseded.accent).toBe('muted');
});
it('accents are unique across roles (each role is visually distinct)', () => {
const accents = roles.map((r) => ROLE_META[r].accent);
expect(new Set(accents).size).toBe(4);
});
it('icons are unique across roles', () => {
const icons = roles.map((r) => ROLE_META[r].icon);
expect(new Set(icons).size).toBe(4);
});
it('labels are human-readable (first letter capital, no accents on the word)', () => {
for (const r of roles) {
const label = ROLE_META[r].label;
expect(label[0]).toBe(label[0].toUpperCase());
}
});
});
describe('roleMetaFor — lookup with defensive fallback', () => {
it('returns the exact entry for a known role', () => {
expect(roleMetaFor('primary')).toBe(ROLE_META.primary);
expect(roleMetaFor('contradicting')).toBe(ROLE_META.contradicting);
});
it('falls back to Supporting when handed an unknown role (deep_reference could add new ones)', () => {
expect(roleMetaFor('unknown-role')).toBe(ROLE_META.supporting);
expect(roleMetaFor('')).toBe(ROLE_META.supporting);
});
});
// ────────────────────────────────────────────────────────────────
// nodeTypeColor — palette lookup with fallback
// ────────────────────────────────────────────────────────────────
describe('nodeTypeColor — palette lookup', () => {
it('returns the fallback colour when nodeType is undefined/null/empty', () => {
expect(nodeTypeColor(undefined)).toBe(DEFAULT_NODE_TYPE_COLOR);
expect(nodeTypeColor(null)).toBe(DEFAULT_NODE_TYPE_COLOR);
expect(nodeTypeColor('')).toBe(DEFAULT_NODE_TYPE_COLOR);
});
it('returns the palette entry for every known NODE_TYPE_COLORS key', () => {
for (const [type, colour] of Object.entries(NODE_TYPE_COLORS)) {
expect(nodeTypeColor(type)).toBe(colour);
}
});
it('returns the fallback for an unknown nodeType', () => {
expect(nodeTypeColor('quantum-state')).toBe(DEFAULT_NODE_TYPE_COLOR);
});
});
// ────────────────────────────────────────────────────────────────
// formatDate — invalid-date handling (the real bug fixed here)
// ────────────────────────────────────────────────────────────────
describe('formatDate — ISO parsing with graceful degradation', () => {
it('formats a valid ISO date into a locale string', () => {
const out = formatDate('2026-04-20T12:00:00.000Z', 'en-US');
// Example: "Apr 20, 2026"
expect(out).toMatch(/2026/);
expect(out).toMatch(/Apr/);
});
it('returns em-dash for empty / null / undefined', () => {
expect(formatDate('')).toBe('—');
expect(formatDate(null)).toBe('—');
expect(formatDate(undefined)).toBe('—');
expect(formatDate(' ')).toBe('—');
});
it('returns the original string when the input is unparseable (never "Invalid Date")', () => {
// Regression: `new Date('not-a-date').toLocaleDateString()` returned
// the literal text "Invalid Date" — EvidenceCard rendered that. Now
// we surface the raw string so a reviewer can tell it was garbage.
const garbage = 'not-a-date';
expect(formatDate(garbage)).toBe(garbage);
expect(formatDate(garbage)).not.toBe('Invalid Date');
});
it('handles ISO dates without time component', () => {
const out = formatDate('2026-01-15', 'en-US');
expect(out).toMatch(/2026/);
});
it('is pure — no global mutation between calls', () => {
const a = formatDate('2026-04-20T00:00:00.000Z', 'en-US');
const b = formatDate('2026-04-20T00:00:00.000Z', 'en-US');
expect(a).toBe(b);
});
});
// ────────────────────────────────────────────────────────────────
// shortenId — UUID → #abcdef01
// ────────────────────────────────────────────────────────────────
describe('shortenId — 8-char display prefix', () => {
it('returns an 8-char prefix for a standard UUID', () => {
expect(shortenId('a1b2c3d4-e5f6-0000-0000-000000000000')).toBe('a1b2c3d4');
});
it('returns the full string when already ≤ 8 chars', () => {
expect(shortenId('abc')).toBe('abc');
expect(shortenId('12345678')).toBe('12345678');
});
it('handles null/undefined/empty gracefully', () => {
expect(shortenId(null)).toBe('');
expect(shortenId(undefined)).toBe('');
expect(shortenId('')).toBe('');
});
it('respects a custom length parameter', () => {
expect(shortenId('abcdefghij', 4)).toBe('abcd');
expect(shortenId('abcdefghij', 10)).toBe('abcdefghij');
});
});

View file

@ -0,0 +1,311 @@
/**
* Tests for schedule / FSRS calendar helpers. These are the pure-logic core
* of the `schedule` page + `FSRSCalendar.svelte` component the Svelte
* runtime is not exercised here (vitest runs `environment: node`, no jsdom).
*/
import { describe, it, expect } from 'vitest';
import type { Memory } from '$types';
import {
MS_DAY,
startOfDay,
daysBetween,
isoDate,
classifyUrgency,
daysUntilReview,
weekBucketRange,
avgRetention,
gridCellPosition,
gridStartForAnchor,
computeScheduleStats,
} from '../schedule-helpers';
function makeMemory(overrides: Partial<Memory> = {}): Memory {
return {
id: 'm-' + Math.random().toString(36).slice(2, 8),
content: 'test memory',
nodeType: 'fact',
tags: [],
retentionStrength: 0.7,
storageStrength: 0.5,
retrievalStrength: 0.8,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
...overrides,
};
}
// Fixed anchor: 2026-04-20 12:00 local so offsets don't straddle midnight
// in the default test runner's tz. All relative timestamps are derived from
// this anchor to keep tests tz-independent.
function anchor(): Date {
const d = new Date(2026, 3, 20, 12, 0, 0, 0); // Mon Apr 20 2026 12:00 local
return d;
}
function offsetDays(base: Date, days: number, hour = 12): Date {
const d = new Date(base);
d.setDate(d.getDate() + days);
d.setHours(hour, 0, 0, 0);
return d;
}
describe('startOfDay', () => {
it('zeros hours / minutes / seconds / ms', () => {
const d = new Date(2026, 3, 20, 14, 35, 27, 999);
const s = startOfDay(d);
expect(s.getHours()).toBe(0);
expect(s.getMinutes()).toBe(0);
expect(s.getSeconds()).toBe(0);
expect(s.getMilliseconds()).toBe(0);
expect(s.getFullYear()).toBe(2026);
expect(s.getMonth()).toBe(3);
expect(s.getDate()).toBe(20);
});
it('does not mutate its input', () => {
const input = new Date(2026, 3, 20, 14, 35);
const before = input.getTime();
startOfDay(input);
expect(input.getTime()).toBe(before);
});
it('accepts an ISO string', () => {
const s = startOfDay('2026-04-20T14:35:00');
expect(s.getHours()).toBe(0);
});
});
describe('daysBetween', () => {
it('returns 0 for the same calendar day at different hours', () => {
const a = new Date(2026, 3, 20, 0, 0);
const b = new Date(2026, 3, 20, 23, 59);
expect(daysBetween(a, b)).toBe(0);
expect(daysBetween(b, a)).toBe(0);
});
it('returns positive for future, negative for past', () => {
const today = anchor();
expect(daysBetween(offsetDays(today, 3), today)).toBe(3);
expect(daysBetween(offsetDays(today, -3), today)).toBe(-3);
});
it('is day-granular across the midnight boundary', () => {
const midnight = new Date(2026, 3, 20, 0, 0, 0, 0);
const justBefore = new Date(2026, 3, 19, 23, 59, 59, 999);
expect(daysBetween(midnight, justBefore)).toBe(1);
});
});
describe('isoDate', () => {
it('formats as YYYY-MM-DD with zero-padding in LOCAL time', () => {
expect(isoDate(new Date(2026, 0, 5))).toBe('2026-01-05'); // jan 5
expect(isoDate(new Date(2026, 11, 31))).toBe('2026-12-31');
});
it('uses local day even for late-evening UTC-crossing timestamps', () => {
// This is the whole reason isoDate uses get* not getUTC*: calendar cells
// should match the user's perceived day.
const d = new Date(2026, 3, 20, 23, 30); // apr 20 23:30 local
expect(isoDate(d)).toBe('2026-04-20');
});
});
describe('classifyUrgency', () => {
const now = anchor();
it('returns "none" for missing nextReviewAt', () => {
expect(classifyUrgency(now, null)).toBe('none');
expect(classifyUrgency(now, undefined)).toBe('none');
expect(classifyUrgency(now, '')).toBe('none');
});
it('returns "none" for unparseable ISO strings', () => {
expect(classifyUrgency(now, 'not-a-date')).toBe('none');
});
it('classifies overdue when due date is strictly before today', () => {
expect(classifyUrgency(now, offsetDays(now, -1).toISOString())).toBe('overdue');
expect(classifyUrgency(now, offsetDays(now, -5).toISOString())).toBe('overdue');
});
it('classifies today when due date is the same calendar day', () => {
// Same day, earlier hour — still today, NOT overdue (day-granular).
const earlier = new Date(now);
earlier.setHours(3, 0);
expect(classifyUrgency(now, earlier.toISOString())).toBe('today');
const later = new Date(now);
later.setHours(22, 0);
expect(classifyUrgency(now, later.toISOString())).toBe('today');
});
it('classifies 1..=7 days out as "week"', () => {
expect(classifyUrgency(now, offsetDays(now, 1).toISOString())).toBe('week');
expect(classifyUrgency(now, offsetDays(now, 7).toISOString())).toBe('week');
});
it('classifies 8+ days out as "future"', () => {
expect(classifyUrgency(now, offsetDays(now, 8).toISOString())).toBe('future');
expect(classifyUrgency(now, offsetDays(now, 30).toISOString())).toBe('future');
});
it('boundary at midnight: 1 second after midnight tomorrow is "week" not "today"', () => {
const tomorrowMidnight = startOfDay(offsetDays(now, 1, 0));
tomorrowMidnight.setSeconds(1);
expect(classifyUrgency(now, tomorrowMidnight.toISOString())).toBe('week');
});
});
describe('daysUntilReview', () => {
const now = anchor();
it('returns null for missing / invalid input', () => {
expect(daysUntilReview(now, null)).toBeNull();
expect(daysUntilReview(now, undefined)).toBeNull();
expect(daysUntilReview(now, 'garbage')).toBeNull();
});
it('returns 0 for today', () => {
expect(daysUntilReview(now, now.toISOString())).toBe(0);
});
it('returns signed integer days', () => {
expect(daysUntilReview(now, offsetDays(now, 5).toISOString())).toBe(5);
expect(daysUntilReview(now, offsetDays(now, -3).toISOString())).toBe(-3);
});
});
describe('weekBucketRange', () => {
it('returns Sunday→Sunday exclusive for any weekday', () => {
// Apr 20 2026 is a Monday. The week starts on Sunday Apr 19.
const mon = new Date(2026, 3, 20, 14, 0);
const { start, end } = weekBucketRange(mon);
expect(start.getDay()).toBe(0); // Sunday
expect(start.getDate()).toBe(19);
expect(end.getDate()).toBe(26); // next Sunday
expect(end.getTime() - start.getTime()).toBe(7 * MS_DAY);
});
it('for Sunday input, returns that same Sunday as start', () => {
const sun = new Date(2026, 3, 19, 10, 0); // Sun Apr 19 2026
const { start } = weekBucketRange(sun);
expect(start.getDate()).toBe(19);
});
});
describe('avgRetention', () => {
it('returns 0 for empty array (no NaN)', () => {
expect(avgRetention([])).toBe(0);
expect(Number.isNaN(avgRetention([]))).toBe(false);
});
it('returns the single value for a length-1 list', () => {
expect(avgRetention([makeMemory({ retentionStrength: 0.42 })])).toBeCloseTo(0.42);
});
it('returns the mean for a mixed list', () => {
const ms = [
makeMemory({ retentionStrength: 0.2 }),
makeMemory({ retentionStrength: 0.8 }),
makeMemory({ retentionStrength: 0.5 }),
];
expect(avgRetention(ms)).toBeCloseTo(0.5);
});
it('tolerates missing retentionStrength (treat as 0)', () => {
const ms = [
makeMemory({ retentionStrength: 1.0 }),
makeMemory({ retentionStrength: undefined as unknown as number }),
];
expect(avgRetention(ms)).toBeCloseTo(0.5);
});
});
describe('gridCellPosition', () => {
it('maps row-major: index 0 → (0,0), index 7 → (1,0), index 41 → (5,6)', () => {
expect(gridCellPosition(0)).toEqual({ row: 0, col: 0 });
expect(gridCellPosition(6)).toEqual({ row: 0, col: 6 });
expect(gridCellPosition(7)).toEqual({ row: 1, col: 0 });
expect(gridCellPosition(15)).toEqual({ row: 2, col: 1 });
expect(gridCellPosition(41)).toEqual({ row: 5, col: 6 });
});
it('returns null for out-of-range or non-integer indices', () => {
expect(gridCellPosition(-1)).toBeNull();
expect(gridCellPosition(42)).toBeNull();
expect(gridCellPosition(100)).toBeNull();
expect(gridCellPosition(3.5)).toBeNull();
});
});
describe('gridStartForAnchor', () => {
it('returns a Sunday at or before anchor-14 days', () => {
// Apr 20 2026 (Mon) → anchor-14 = Apr 6 2026 (Mon) → back to Sun Apr 5.
const start = gridStartForAnchor(anchor());
expect(start.getDay()).toBe(0);
expect(start.getFullYear()).toBe(2026);
expect(start.getMonth()).toBe(3);
expect(start.getDate()).toBe(5);
expect(start.getHours()).toBe(0);
});
it('includes today in the 6-week window (row 2 or 3)', () => {
const today = anchor();
const start = gridStartForAnchor(today);
const delta = daysBetween(today, start);
expect(delta).toBeGreaterThanOrEqual(14);
expect(delta).toBeLessThan(42);
});
});
describe('computeScheduleStats', () => {
const now = anchor();
it('zeros everything for an empty corpus', () => {
const s = computeScheduleStats(now, []);
expect(s).toEqual({
overdue: 0,
dueToday: 0,
dueThisWeek: 0,
dueThisMonth: 0,
avgDays: 0,
});
});
it('counts each bucket independently (today ⊂ week ⊂ month)', () => {
const ms = [
makeMemory({ nextReviewAt: offsetDays(now, -2).toISOString() }), // overdue
makeMemory({ nextReviewAt: new Date(now).toISOString() }), // today
makeMemory({ nextReviewAt: offsetDays(now, 3).toISOString() }), // week
makeMemory({ nextReviewAt: offsetDays(now, 15).toISOString() }), // month
makeMemory({ nextReviewAt: offsetDays(now, 45).toISOString() }), // out of month
];
const s = computeScheduleStats(now, ms);
expect(s.overdue).toBe(1);
expect(s.dueToday).toBe(2); // overdue + today (delta <= 0)
expect(s.dueThisWeek).toBe(3); // overdue + today + week
expect(s.dueThisMonth).toBe(4); // overdue + today + week + month
});
it('skips memories without a nextReviewAt or with unparseable dates', () => {
const ms = [
makeMemory({ nextReviewAt: undefined }),
makeMemory({ nextReviewAt: 'bogus' }),
makeMemory({ nextReviewAt: offsetDays(now, 2).toISOString() }),
];
const s = computeScheduleStats(now, ms);
expect(s.dueThisWeek).toBe(1);
});
it('computes average days across future-only memories', () => {
const ms = [
makeMemory({ nextReviewAt: offsetDays(now, -5).toISOString() }), // excluded (past)
makeMemory({ nextReviewAt: offsetDays(now, 2).toISOString() }),
makeMemory({ nextReviewAt: offsetDays(now, 4).toISOString() }),
];
const s = computeScheduleStats(now, ms);
// avgDays is measured from today-at-midnight (not now-mid-day), so a
// review tomorrow at noon is 1.5 days out. Two memories at +2d and +4d
// (both hour=12) → (2.5 + 4.5) / 2 = 3.5.
expect(s.avgDays).toBeCloseTo(3.5, 2);
});
});

View file

@ -0,0 +1,417 @@
/**
* Unit tests for importance-helpers the pure logic backing
* ImportanceRadar.svelte + importance/+page.svelte.
*
* Runs in the vitest `node` environment (no jsdom). We exercise:
* - Composite channel weighting (matches backend ImportanceSignals)
* - 4-axis radar vertex geometry (Novelty top / Arousal right / Reward
* bottom / Attention left)
* - Value clamping at the helper boundary (defensive against a mis-
* scaled /api/importance response)
* - Size-preset mapping (sm 80 / md 180 / lg 320)
* - Trending-memory importance proxy (retention × log(reviews) / age)
* including the age=0 division-by-zero edge case.
*/
import { describe, it, expect } from 'vitest';
import {
clamp01,
clampChannels,
compositeScore,
CHANNEL_WEIGHTS,
sizePreset,
radarRadius,
radarVertices,
verticesToPath,
importanceProxy,
rankByProxy,
AXIS_ORDER,
SIZE_PX,
type ProxyMemoryLike,
} from '../importance-helpers';
// ===========================================================================
// clamp01
// ===========================================================================
describe('clamp01', () => {
it('passes in-range values through', () => {
expect(clamp01(0)).toBe(0);
expect(clamp01(0.5)).toBe(0.5);
expect(clamp01(1)).toBe(1);
});
it('clamps below zero to 0', () => {
expect(clamp01(-0.3)).toBe(0);
expect(clamp01(-100)).toBe(0);
});
it('clamps above one to 1', () => {
expect(clamp01(1.0001)).toBe(1);
expect(clamp01(42)).toBe(1);
});
it('folds null / undefined / NaN / Infinity to 0', () => {
expect(clamp01(null)).toBe(0);
expect(clamp01(undefined)).toBe(0);
expect(clamp01(NaN)).toBe(0);
expect(clamp01(Infinity)).toBe(0);
expect(clamp01(-Infinity)).toBe(0);
});
});
describe('clampChannels', () => {
it('clamps every channel independently', () => {
expect(clampChannels({ novelty: 2, arousal: -1, reward: 0.5, attention: NaN })).toEqual({
novelty: 1,
arousal: 0,
reward: 0.5,
attention: 0,
});
});
it('fills missing channels with 0', () => {
expect(clampChannels({ novelty: 0.8 })).toEqual({
novelty: 0.8,
arousal: 0,
reward: 0,
attention: 0,
});
});
it('accepts null / undefined as "all zeros"', () => {
expect(clampChannels(null)).toEqual({ novelty: 0, arousal: 0, reward: 0, attention: 0 });
expect(clampChannels(undefined)).toEqual({
novelty: 0,
arousal: 0,
reward: 0,
attention: 0,
});
});
});
// ===========================================================================
// compositeScore — MUST match backend ImportanceSignals weights
// ===========================================================================
describe('compositeScore', () => {
it('sums channel contributions with the documented weights', () => {
const c = { novelty: 1, arousal: 1, reward: 1, attention: 1 };
// 0.25 + 0.30 + 0.25 + 0.20 = 1.00
expect(compositeScore(c)).toBeCloseTo(1.0, 5);
});
it('is zero for all-zero channels', () => {
expect(compositeScore({ novelty: 0, arousal: 0, reward: 0, attention: 0 })).toBe(0);
});
it('weights match CHANNEL_WEIGHTS exactly (backend contract)', () => {
expect(CHANNEL_WEIGHTS).toEqual({
novelty: 0.25,
arousal: 0.3,
reward: 0.25,
attention: 0.2,
});
// Weights sum to 1 — any drift here and the "composite ∈ [0,1]"
// invariant falls over.
const sum =
CHANNEL_WEIGHTS.novelty +
CHANNEL_WEIGHTS.arousal +
CHANNEL_WEIGHTS.reward +
CHANNEL_WEIGHTS.attention;
expect(sum).toBeCloseTo(1.0, 10);
});
it('matches the exact weighted formula per channel', () => {
// 0.4·0.25 + 0.6·0.30 + 0.2·0.25 + 0.8·0.20
// = 0.10 + 0.18 + 0.05 + 0.16 = 0.49
expect(
compositeScore({ novelty: 0.4, arousal: 0.6, reward: 0.2, attention: 0.8 }),
).toBeCloseTo(0.49, 5);
});
it('clamps inputs before weighting (never escapes [0,1])', () => {
// All over-max → should pin to 1, not to 2.
expect(
compositeScore({ novelty: 2, arousal: 2, reward: 2, attention: 2 }),
).toBeCloseTo(1.0, 5);
// Negative channels count as 0.
expect(
compositeScore({ novelty: -1, arousal: -1, reward: -1, attention: -1 }),
).toBe(0);
});
});
// ===========================================================================
// Size preset
// ===========================================================================
describe('sizePreset', () => {
it('maps the three documented presets', () => {
expect(sizePreset('sm')).toBe(80);
expect(sizePreset('md')).toBe(180);
expect(sizePreset('lg')).toBe(320);
});
it('exposes the SIZE_PX mapping for external consumers', () => {
expect(SIZE_PX).toEqual({ sm: 80, md: 180, lg: 320 });
});
it('falls back to md (180) for unknown / missing keys', () => {
expect(sizePreset(undefined)).toBe(180);
expect(sizePreset('' as unknown as 'md')).toBe(180);
expect(sizePreset('xl' as unknown as 'md')).toBe(180);
});
});
// ===========================================================================
// radarRadius — component padding rules
// ===========================================================================
describe('radarRadius', () => {
it('applies the correct padding per preset', () => {
// sm: 80/2 - 4 = 36
// md: 180/2 - 28 = 62
// lg: 320/2 - 44 = 116
expect(radarRadius('sm')).toBe(36);
expect(radarRadius('md')).toBe(62);
expect(radarRadius('lg')).toBe(116);
});
it('never returns a negative radius', () => {
// Can't construct a sub-zero radius via normal presets, but the
// helper floors at 0 defensively.
expect(radarRadius('md')).toBeGreaterThanOrEqual(0);
});
});
// ===========================================================================
// radarVertices — 4 SVG polygon points on the fixed axis order
// ===========================================================================
describe('radarVertices', () => {
it('emits vertices in Novelty→Arousal→Reward→Attention order', () => {
expect(AXIS_ORDER.map((a) => a.key)).toEqual([
'novelty',
'arousal',
'reward',
'attention',
]);
});
it('places a 0-valued channel at the centre', () => {
// Centre for md is (90, 90). novelty=0 means the top vertex sits AT
// the centre — the polygon pinches inward.
const v = radarVertices(
{ novelty: 0, arousal: 0, reward: 0, attention: 0 },
'md',
);
expect(v).toHaveLength(4);
for (const p of v) {
expect(p.x).toBeCloseTo(90, 5);
expect(p.y).toBeCloseTo(90, 5);
}
});
it('places a 1-valued channel on the correct axis edge', () => {
// Size md: cx=cy=90, r=62.
// Novelty (angle -π/2, top) → (90, 90 - 62) = (90, 28)
// Arousal (angle 0, right) → (90 + 62, 90) = (152, 90)
// Reward (angle π/2, bottom) → (90, 90 + 62) = (90, 152)
// Attention (angle π, left) → (90 - 62, 90) = (28, 90)
const v = radarVertices(
{ novelty: 1, arousal: 1, reward: 1, attention: 1 },
'md',
);
expect(v[0].x).toBeCloseTo(90, 5);
expect(v[0].y).toBeCloseTo(28, 5);
expect(v[1].x).toBeCloseTo(152, 5);
expect(v[1].y).toBeCloseTo(90, 5);
expect(v[2].x).toBeCloseTo(90, 5);
expect(v[2].y).toBeCloseTo(152, 5);
expect(v[3].x).toBeCloseTo(28, 5);
expect(v[3].y).toBeCloseTo(90, 5);
});
it('scales vertex radial distance linearly with the channel value', () => {
// Arousal at 0.5 should land half-way from centre to the right edge.
const v = radarVertices(
{ novelty: 0, arousal: 0.5, reward: 0, attention: 0 },
'md',
);
// radius=62, so right vertex x = 90 + 62*0.5 = 121.
expect(v[1].x).toBeCloseTo(121, 5);
expect(v[1].y).toBeCloseTo(90, 5);
});
it('clamps out-of-range inputs rather than exiting the SVG box', () => {
// novelty=2 should pin to the edge (not overshoot to 90 - 124 = -34).
const v = radarVertices(
{ novelty: 2, arousal: -0.5, reward: NaN, attention: Infinity },
'md',
);
// Novelty pinned to edge (y=28), arousal/reward/attention at 0 land at centre.
expect(v[0].y).toBeCloseTo(28, 5);
expect(v[1].x).toBeCloseTo(90, 5); // arousal=0 → centre
expect(v[2].y).toBeCloseTo(90, 5); // reward=0 → centre
expect(v[3].x).toBeCloseTo(90, 5); // attention=0 → centre
});
it('respects the active size preset', () => {
// At sm (80px), radius=36. Novelty=1 → (40, 40-36) = (40, 4).
const v = radarVertices({ novelty: 1, arousal: 0, reward: 0, attention: 0 }, 'sm');
expect(v[0].x).toBeCloseTo(40, 5);
expect(v[0].y).toBeCloseTo(4, 5);
});
});
describe('verticesToPath', () => {
it('serialises to an SVG path with M/L commands and Z close', () => {
const path = verticesToPath([
{ x: 10, y: 20 },
{ x: 30, y: 40 },
{ x: 50, y: 60 },
{ x: 70, y: 80 },
]);
expect(path).toBe('M10.00,20.00 L30.00,40.00 L50.00,60.00 L70.00,80.00 Z');
});
it('returns an empty string for no points', () => {
expect(verticesToPath([])).toBe('');
});
});
// ===========================================================================
// importanceProxy — "Top Important Memories This Week" ranking formula
// ===========================================================================
describe('importanceProxy', () => {
// Anchor everything to a fixed "now" so recency math is deterministic.
const NOW = new Date('2026-04-20T12:00:00Z').getTime();
function mem(over: Partial<ProxyMemoryLike>): ProxyMemoryLike {
return {
retentionStrength: 0.5,
reviewCount: 0,
createdAt: new Date(NOW - 2 * 86_400_000).toISOString(),
...over,
};
}
it('is zero for zero retention', () => {
expect(importanceProxy(mem({ retentionStrength: 0 }), NOW)).toBe(0);
});
it('treats missing reviewCount as 0 (not a crash)', () => {
const m = mem({ reviewCount: undefined, retentionStrength: 0.8 });
const v = importanceProxy(m, NOW);
expect(v).toBeGreaterThan(0);
expect(Number.isFinite(v)).toBe(true);
});
it('matches the documented formula: retention × log1p(reviews+1) / √age', () => {
// createdAt = 4 days before NOW → ageDays = 4, √4 = 2.
// retention = 0.6, reviews = 3 → log1p(4) ≈ 1.6094
// expected = 0.6 × 1.6094 / 2 ≈ 0.4828
const m = mem({
retentionStrength: 0.6,
reviewCount: 3,
createdAt: new Date(NOW - 4 * 86_400_000).toISOString(),
});
const v = importanceProxy(m, NOW);
const expected = (0.6 * Math.log1p(4)) / 2;
expect(v).toBeCloseTo(expected, 6);
});
it('clamps age to 1 day for a memory created RIGHT NOW (div-by-zero guard)', () => {
// createdAt equals NOW → raw ageDays = 0. Without the clamp, the
// recency boost would divide by zero. We assert the helper returns
// a finite value equal to the "age=1" path.
const zeroAge = importanceProxy(
mem({
retentionStrength: 0.5,
reviewCount: 0,
createdAt: new Date(NOW).toISOString(),
}),
NOW,
);
const oneDayAge = importanceProxy(
mem({
retentionStrength: 0.5,
reviewCount: 0,
createdAt: new Date(NOW - 1 * 86_400_000).toISOString(),
}),
NOW,
);
expect(Number.isFinite(zeroAge)).toBe(true);
expect(zeroAge).toBeCloseTo(oneDayAge, 10);
});
it('also clamps future-dated memories to ageDays=1 rather than going negative', () => {
const future = importanceProxy(
mem({
retentionStrength: 0.5,
reviewCount: 0,
createdAt: new Date(NOW + 7 * 86_400_000).toISOString(),
}),
NOW,
);
expect(Number.isFinite(future)).toBe(true);
expect(future).toBeGreaterThan(0);
});
it('returns 0 for a malformed createdAt', () => {
const m = {
retentionStrength: 0.8,
reviewCount: 3,
createdAt: 'not-a-date',
};
expect(importanceProxy(m, NOW)).toBe(0);
});
it('returns 0 when retentionStrength is non-finite', () => {
expect(importanceProxy(mem({ retentionStrength: NaN }), NOW)).toBe(0);
expect(importanceProxy(mem({ retentionStrength: Infinity }), NOW)).toBe(0);
});
it('ranks recent + high-retention memories ahead of stale ones', () => {
const fresh: ProxyMemoryLike = {
retentionStrength: 0.9,
reviewCount: 5,
createdAt: new Date(NOW - 1 * 86_400_000).toISOString(),
};
const stale: ProxyMemoryLike = {
retentionStrength: 0.9,
reviewCount: 5,
createdAt: new Date(NOW - 100 * 86_400_000).toISOString(),
};
expect(importanceProxy(fresh, NOW)).toBeGreaterThan(importanceProxy(stale, NOW));
});
});
describe('rankByProxy', () => {
const NOW = new Date('2026-04-20T12:00:00Z').getTime();
it('sorts descending by the proxy score', () => {
const items: (ProxyMemoryLike & { id: string })[] = [
{ id: 'stale', retentionStrength: 0.9, reviewCount: 5, createdAt: new Date(NOW - 100 * 86_400_000).toISOString() },
{ id: 'fresh', retentionStrength: 0.9, reviewCount: 5, createdAt: new Date(NOW - 1 * 86_400_000).toISOString() },
{ id: 'dead', retentionStrength: 0.0, reviewCount: 0, createdAt: new Date(NOW - 2 * 86_400_000).toISOString() },
];
const ranked = rankByProxy(items, NOW);
expect(ranked.map((r) => r.id)).toEqual(['fresh', 'stale', 'dead']);
});
it('does not mutate the input array', () => {
const items: ProxyMemoryLike[] = [
{ retentionStrength: 0.1, reviewCount: 0, createdAt: new Date(NOW - 10 * 86_400_000).toISOString() },
{ retentionStrength: 0.9, reviewCount: 9, createdAt: new Date(NOW - 1 * 86_400_000).toISOString() },
];
const before = items.slice();
rankByProxy(items, NOW);
expect(items).toEqual(before);
});
});

View file

@ -0,0 +1,298 @@
/**
* MemoryAuditTrail pure helper coverage.
*
* Runs in vitest's Node environment (no jsdom). Every assertion exercises
* a function in `audit-trail-helpers.ts` with fully deterministic inputs.
*/
import { describe, it, expect } from 'vitest';
import {
ALL_ACTIONS,
META,
VISIBLE_LIMIT,
formatRetentionDelta,
generateMockAuditTrail,
hashSeed,
makeRand,
relativeTime,
splitVisible,
type AuditAction,
type AuditEvent
} from '../audit-trail-helpers';
// Fixed reference point for all time-based tests. Millisecond precision so
// relative-time maths are exact, not drifting with wallclock time.
const NOW = Date.UTC(2026, 3, 20, 12, 0, 0); // 2026-04-20 12:00:00 UTC
// ---------------------------------------------------------------------------
// hashSeed + makeRand
// ---------------------------------------------------------------------------
describe('hashSeed', () => {
it('is deterministic', () => {
expect(hashSeed('abc')).toBe(hashSeed('abc'));
expect(hashSeed('memory-42')).toBe(hashSeed('memory-42'));
});
it('different ids hash to different seeds', () => {
expect(hashSeed('a')).not.toBe(hashSeed('b'));
expect(hashSeed('memory-1')).not.toBe(hashSeed('memory-2'));
});
it('empty string hashes to 0', () => {
expect(hashSeed('')).toBe(0);
});
it('returns an unsigned 32-bit integer', () => {
// Stress: a long id should never produce a negative or non-integer seed.
const seed = hashSeed('a'.repeat(256));
expect(Number.isInteger(seed)).toBe(true);
expect(seed).toBeGreaterThanOrEqual(0);
expect(seed).toBeLessThan(2 ** 32);
});
});
describe('makeRand', () => {
it('is deterministic given the same seed', () => {
const a = makeRand(42);
const b = makeRand(42);
for (let i = 0; i < 20; i++) expect(a()).toBe(b());
});
it('produces values strictly in [0, 1)', () => {
// Seed with UINT32_MAX to force the edge case that exposed the original
// `/ 0xffffffff` bug — the divisor must be 2^32, not 2^32 - 1.
const rand = makeRand(0xffffffff);
for (let i = 0; i < 5000; i++) {
const v = rand();
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThan(1);
}
});
it('different seeds produce different sequences', () => {
const a = makeRand(1);
const b = makeRand(2);
expect(a()).not.toBe(b());
});
});
// ---------------------------------------------------------------------------
// Deterministic generator
// ---------------------------------------------------------------------------
describe('generateMockAuditTrail — determinism', () => {
it('same id + same now always yields the same sequence', () => {
const a = generateMockAuditTrail('memory-xyz', NOW);
const b = generateMockAuditTrail('memory-xyz', NOW);
expect(a).toEqual(b);
});
it('different ids yield different sequences', () => {
const a = generateMockAuditTrail('memory-a', NOW);
const b = generateMockAuditTrail('memory-b', NOW);
// Either different lengths or different event-by-event — anything but equal.
expect(a).not.toEqual(b);
});
it('empty id yields no events — the panel should never fabricate history', () => {
expect(generateMockAuditTrail('', NOW)).toEqual([]);
});
it('count fits the default 8-15 range', () => {
// Sample a handful of ids — the distribution should stay in range.
for (const id of ['a', 'abc', 'memory-1', 'memory-2', 'memory-3', 'x'.repeat(50)]) {
const events = generateMockAuditTrail(id, NOW);
expect(events.length).toBeGreaterThanOrEqual(8);
expect(events.length).toBeLessThanOrEqual(15);
}
});
it('first emitted event (newest-first order → last in array) is "created"', () => {
const events = generateMockAuditTrail('deterministic-id', NOW);
expect(events[events.length - 1].action).toBe('created');
expect(events[events.length - 1].triggered_by).toBe('smart_ingest');
});
it('emits events in newest-first order', () => {
const events = generateMockAuditTrail('order-check', NOW);
for (let i = 1; i < events.length; i++) {
const prev = new Date(events[i - 1].timestamp).getTime();
const curr = new Date(events[i].timestamp).getTime();
expect(prev).toBeGreaterThanOrEqual(curr);
}
});
it('all timestamps are valid ISO strings in the past relative to NOW', () => {
const events = generateMockAuditTrail('iso-check', NOW);
for (const ev of events) {
const t = new Date(ev.timestamp).getTime();
expect(Number.isFinite(t)).toBe(true);
expect(t).toBeLessThanOrEqual(NOW);
}
});
it('respects countOverride — 16 events crosses the visibility threshold', () => {
const events = generateMockAuditTrail('big', NOW, 16);
expect(events).toHaveLength(16);
});
it('retention values never escape [0, 1]', () => {
for (const id of ['x', 'y', 'z', 'memory-big']) {
const events = generateMockAuditTrail(id, NOW, 30);
for (const ev of events) {
if (ev.old_value !== undefined) {
expect(ev.old_value).toBeGreaterThanOrEqual(0);
expect(ev.old_value).toBeLessThanOrEqual(1);
}
if (ev.new_value !== undefined) {
expect(ev.new_value).toBeGreaterThanOrEqual(0);
expect(ev.new_value).toBeLessThanOrEqual(1);
}
}
}
});
});
// ---------------------------------------------------------------------------
// Relative time
// ---------------------------------------------------------------------------
describe('relativeTime — boundary cases', () => {
// Build an ISO timestamp `offsetMs` before NOW.
const ago = (offsetMs: number) => new Date(NOW - offsetMs).toISOString();
const cases: Array<[string, number, string]> = [
['0s ago', 0, '0s ago'],
['59s ago', 59 * 1000, '59s ago'],
['60s flips to 1m', 60 * 1000, '1m ago'],
['59m ago', 59 * 60 * 1000, '59m ago'],
['60m flips to 1h', 60 * 60 * 1000, '1h ago'],
['23h ago', 23 * 3600 * 1000, '23h ago'],
['24h flips to 1d', 24 * 3600 * 1000, '1d ago'],
['6d ago', 6 * 86400 * 1000, '6d ago'],
['7d ago', 7 * 86400 * 1000, '7d ago'],
['29d ago', 29 * 86400 * 1000, '29d ago'],
['30d flips to 1mo', 30 * 86400 * 1000, '1mo ago'],
['365d → 12mo flips to 1y', 365 * 86400 * 1000, '1y ago']
];
for (const [name, offset, expected] of cases) {
it(name, () => {
expect(relativeTime(ago(offset), NOW)).toBe(expected);
});
}
it('future timestamps clamp to "0s ago"', () => {
const future = new Date(NOW + 60_000).toISOString();
expect(relativeTime(future, NOW)).toBe('0s ago');
});
});
// ---------------------------------------------------------------------------
// Event type → marker mapping
// ---------------------------------------------------------------------------
describe('META — action to marker mapping', () => {
it('covers all 8 audit actions exactly', () => {
expect(Object.keys(META).sort()).toEqual([...ALL_ACTIONS].sort());
expect(ALL_ACTIONS).toHaveLength(8);
});
it('every action has a distinct marker kind (8 kinds → 8 glyph shapes)', () => {
const kinds = ALL_ACTIONS.map((a) => META[a].kind);
expect(new Set(kinds).size).toBe(8);
});
it('every action has a non-empty label and hex color', () => {
for (const action of ALL_ACTIONS) {
const m = META[action];
expect(m.label.length).toBeGreaterThan(0);
expect(m.color).toMatch(/^#[0-9a-f]{6}$/i);
}
});
});
// ---------------------------------------------------------------------------
// Retention delta formatter
// ---------------------------------------------------------------------------
describe('formatRetentionDelta', () => {
it('returns null when both values are missing', () => {
expect(formatRetentionDelta(undefined, undefined)).toBeNull();
});
it('returns "set X.XX" when only new is defined', () => {
expect(formatRetentionDelta(undefined, 0.5)).toBe('set 0.50');
// Note: toFixed(2) uses float-to-string half-to-even; assert on values
// that round unambiguously rather than on IEEE-754 tie edges.
expect(formatRetentionDelta(undefined, 0.736)).toBe('set 0.74');
});
it('returns "was X.XX" when only old is defined', () => {
expect(formatRetentionDelta(0.5, undefined)).toBe('was 0.50');
});
it('returns "old → new" when both are defined', () => {
expect(formatRetentionDelta(0.5, 0.7)).toBe('0.50 → 0.70');
expect(formatRetentionDelta(0.72, 0.85)).toBe('0.72 → 0.85');
});
it('handles descending deltas without changing the arrow', () => {
// Suppression / demotion paths — old > new.
expect(formatRetentionDelta(0.8, 0.6)).toBe('0.80 → 0.60');
});
it('rejects non-finite numbers', () => {
expect(formatRetentionDelta(NaN, 0.5)).toBe('set 0.50');
expect(formatRetentionDelta(0.5, NaN)).toBe('was 0.50');
expect(formatRetentionDelta(NaN, NaN)).toBeNull();
});
});
// ---------------------------------------------------------------------------
// splitVisible — 15-event cap
// ---------------------------------------------------------------------------
describe('splitVisible — collapse threshold', () => {
const makeEvents = (n: number): AuditEvent[] =>
Array.from({ length: n }, (_, i) => ({
action: 'accessed' as AuditAction,
timestamp: new Date(NOW - i * 60_000).toISOString()
}));
it('VISIBLE_LIMIT is 15', () => {
expect(VISIBLE_LIMIT).toBe(15);
});
it('exactly 15 events → no toggle (hiddenCount 0)', () => {
const { visible, hiddenCount } = splitVisible(makeEvents(15), false);
expect(visible).toHaveLength(15);
expect(hiddenCount).toBe(0);
});
it('14 events → no toggle', () => {
const { visible, hiddenCount } = splitVisible(makeEvents(14), false);
expect(visible).toHaveLength(14);
expect(hiddenCount).toBe(0);
});
it('16 events collapsed → visible 15, hidden 1', () => {
const { visible, hiddenCount } = splitVisible(makeEvents(16), false);
expect(visible).toHaveLength(15);
expect(hiddenCount).toBe(1);
});
it('16 events expanded → visible 16, hidden reports overflow count (1)', () => {
const { visible, hiddenCount } = splitVisible(makeEvents(16), true);
expect(visible).toHaveLength(16);
expect(hiddenCount).toBe(1);
});
it('0 events → visible empty, hidden 0', () => {
const { visible, hiddenCount } = splitVisible(makeEvents(0), false);
expect(visible).toHaveLength(0);
expect(hiddenCount).toBe(0);
});
it('preserves newest-first order when truncating', () => {
const events = makeEvents(20);
const { visible } = splitVisible(events, false);
expect(visible[0]).toBe(events[0]);
expect(visible[14]).toBe(events[14]);
});
});

View file

@ -0,0 +1,334 @@
/**
* Unit tests for patterns-helpers the pure logic backing
* PatternTransferHeatmap.svelte + patterns/+page.svelte.
*
* Runs in the vitest `node` environment (no jsdom). We never touch Svelte
* component internals here only the exported helpers in patterns-helpers.ts.
* Component-level integration (click, hover, DOM wiring) is covered by the
* Playwright e2e suite; this file is pure-logic coverage of the contracts.
*/
import { describe, it, expect } from 'vitest';
import {
cellIntensity,
filterByCategory,
buildTransferMatrix,
matrixMaxCount,
flattenNonZero,
shortProjectName,
PATTERN_CATEGORIES,
type TransferPatternLike,
} from '../patterns-helpers';
// ---------------------------------------------------------------------------
// Test fixtures — mirror the mockFetchCrossProject shape in
// patterns/+page.svelte, but small enough to reason about by hand.
// ---------------------------------------------------------------------------
const PROJECTS = ['vestige', 'nullgaze', 'injeranet'] as const;
const PATTERNS: TransferPatternLike[] = [
{
name: 'Result<T, E>',
category: 'ErrorHandling',
origin_project: 'vestige',
transferred_to: ['nullgaze', 'injeranet'],
transfer_count: 2,
},
{
name: 'Axum middleware',
category: 'ErrorHandling',
origin_project: 'nullgaze',
transferred_to: ['vestige'],
transfer_count: 1,
},
{
name: 'proptest',
category: 'Testing',
origin_project: 'vestige',
transferred_to: ['nullgaze'],
transfer_count: 1,
},
{
name: 'Self-reuse pattern',
category: 'Architecture',
origin_project: 'vestige',
transferred_to: ['vestige'], // diagonal — self-reuse
transfer_count: 1,
},
];
// ===========================================================================
// cellIntensity — 0..1 opacity normaliser
// ===========================================================================
describe('cellIntensity', () => {
it('returns 0 for a zero count', () => {
expect(cellIntensity(0, 10)).toBe(0);
});
it('returns 1 at max', () => {
expect(cellIntensity(10, 10)).toBe(1);
});
it('returns 1 when count exceeds max (defensive clamp)', () => {
expect(cellIntensity(15, 10)).toBe(1);
});
it('scales linearly between 0 and max', () => {
expect(cellIntensity(3, 10)).toBeCloseTo(0.3, 5);
expect(cellIntensity(5, 10)).toBeCloseTo(0.5, 5);
expect(cellIntensity(7, 10)).toBeCloseTo(0.7, 5);
});
it('returns 0 when max is 0 (div-by-zero guard)', () => {
expect(cellIntensity(5, 0)).toBe(0);
});
it('returns 0 for negative counts', () => {
expect(cellIntensity(-1, 10)).toBe(0);
});
it('returns 0 for NaN inputs', () => {
expect(cellIntensity(NaN, 10)).toBe(0);
expect(cellIntensity(5, NaN)).toBe(0);
});
it('returns 0 for Infinity inputs', () => {
expect(cellIntensity(Infinity, 10)).toBe(0);
expect(cellIntensity(5, Infinity)).toBe(0);
});
});
// ===========================================================================
// filterByCategory — drives both heatmap + sidebar reflow
// ===========================================================================
describe('filterByCategory', () => {
it("returns every pattern for 'All'", () => {
const out = filterByCategory(PATTERNS, 'All');
expect(out).toHaveLength(PATTERNS.length);
// Should NOT return the same reference — helpers return a copy so
// callers can mutate freely.
expect(out).not.toBe(PATTERNS);
});
it('filters strictly by category equality', () => {
const errorOnly = filterByCategory(PATTERNS, 'ErrorHandling');
expect(errorOnly).toHaveLength(2);
expect(errorOnly.every((p) => p.category === 'ErrorHandling')).toBe(true);
});
it('returns exactly one match for Testing', () => {
const testing = filterByCategory(PATTERNS, 'Testing');
expect(testing).toHaveLength(1);
expect(testing[0].name).toBe('proptest');
});
it('returns an empty array for a category with no patterns', () => {
const perf = filterByCategory(PATTERNS, 'Performance');
expect(perf).toEqual([]);
});
it('returns an empty array for an unknown category string (no silent alias)', () => {
// This is the "unknown category fallback" contract — we do NOT
// quietly fall back to 'All'. An unknown category is a caller bug
// and yields an empty list so the empty-state UI renders.
expect(filterByCategory(PATTERNS, 'NotARealCategory')).toEqual([]);
expect(filterByCategory(PATTERNS, '')).toEqual([]);
});
it('accepts an empty input array for any category', () => {
expect(filterByCategory([], 'All')).toEqual([]);
expect(filterByCategory([], 'ErrorHandling')).toEqual([]);
expect(filterByCategory([], 'BogusCategory')).toEqual([]);
});
it('exposes all six supported categories', () => {
expect([...PATTERN_CATEGORIES]).toEqual([
'ErrorHandling',
'AsyncConcurrency',
'Testing',
'Architecture',
'Performance',
'Security',
]);
});
});
// ===========================================================================
// buildTransferMatrix — directional N×N projects × projects grid
// ===========================================================================
describe('buildTransferMatrix', () => {
it('constructs an N×N matrix over the projects axis', () => {
const m = buildTransferMatrix(PROJECTS, []);
for (const from of PROJECTS) {
for (const to of PROJECTS) {
expect(m[from][to]).toEqual({ count: 0, topNames: [] });
}
}
});
it('aggregates transfer counts directionally', () => {
const m = buildTransferMatrix(PROJECTS, PATTERNS);
// vestige → nullgaze: Result<T,E> + proptest = 2
expect(m.vestige.nullgaze.count).toBe(2);
// vestige → injeranet: Result<T,E> only = 1
expect(m.vestige.injeranet.count).toBe(1);
// nullgaze → vestige: Axum middleware = 1
expect(m.nullgaze.vestige.count).toBe(1);
// injeranet → anywhere: zero (no origin in injeranet in fixtures)
expect(m.injeranet.vestige.count).toBe(0);
expect(m.injeranet.nullgaze.count).toBe(0);
});
it('treats (A, B) and (B, A) as distinct directions (asymmetry confirmed)', () => {
// The component's doc-comment says "Rows = origin project · Columns =
// destination project" — the matrix MUST be directional. A copy-paste
// bug that aggregates both directions into the same cell would pass
// the "count" test above but fail this symmetry check.
const m = buildTransferMatrix(PROJECTS, PATTERNS);
expect(m.vestige.nullgaze.count).not.toBe(m.nullgaze.vestige.count);
});
it('records self-transfer on the diagonal', () => {
const m = buildTransferMatrix(PROJECTS, PATTERNS);
expect(m.vestige.vestige.count).toBe(1);
expect(m.vestige.vestige.topNames).toEqual(['Self-reuse pattern']);
});
it('captures top pattern names per cell, capped at 3', () => {
const manyPatterns: TransferPatternLike[] = Array.from({ length: 5 }, (_, i) => ({
name: `pattern-${i}`,
category: 'ErrorHandling',
origin_project: 'vestige',
transferred_to: ['nullgaze'],
transfer_count: 1,
}));
const m = buildTransferMatrix(['vestige', 'nullgaze'], manyPatterns);
expect(m.vestige.nullgaze.count).toBe(5);
expect(m.vestige.nullgaze.topNames).toHaveLength(3);
expect(m.vestige.nullgaze.topNames).toEqual(['pattern-0', 'pattern-1', 'pattern-2']);
});
it('silently drops patterns whose origin is not in the projects axis', () => {
const orphan: TransferPatternLike = {
name: 'Orphan',
category: 'Security',
origin_project: 'ghost-project',
transferred_to: ['vestige'],
transfer_count: 1,
};
const m = buildTransferMatrix(PROJECTS, [orphan]);
// Nothing anywhere in the matrix should have ticked up.
const total = matrixMaxCount(PROJECTS, m);
expect(total).toBe(0);
// Matrix structure intact — no ghost key added.
expect((m as Record<string, unknown>)['ghost-project']).toBeUndefined();
});
it('silently drops transferred_to entries not in the projects axis', () => {
const strayDest: TransferPatternLike = {
name: 'StrayDest',
category: 'Security',
origin_project: 'vestige',
transferred_to: ['ghost-project', 'nullgaze'],
transfer_count: 2,
};
const m = buildTransferMatrix(PROJECTS, [strayDest]);
// The known destination counts; the ghost doesn't.
expect(m.vestige.nullgaze.count).toBe(1);
expect((m.vestige as Record<string, unknown>)['ghost-project']).toBeUndefined();
});
it('respects a custom top-name cap', () => {
const pats: TransferPatternLike[] = [
{
name: 'a',
category: 'Testing',
origin_project: 'vestige',
transferred_to: ['nullgaze'],
transfer_count: 1,
},
{
name: 'b',
category: 'Testing',
origin_project: 'vestige',
transferred_to: ['nullgaze'],
transfer_count: 1,
},
];
const m = buildTransferMatrix(['vestige', 'nullgaze'], pats, 1);
expect(m.vestige.nullgaze.topNames).toEqual(['a']);
});
});
// ===========================================================================
// matrixMaxCount
// ===========================================================================
describe('matrixMaxCount', () => {
it('returns 0 for an empty matrix (div-by-zero guard prerequisite)', () => {
const m = buildTransferMatrix(PROJECTS, []);
expect(matrixMaxCount(PROJECTS, m)).toBe(0);
});
it('returns the hottest cell count across all pairs', () => {
const m = buildTransferMatrix(PROJECTS, PATTERNS);
// vestige→nullgaze has 2; everything else is ≤1
expect(matrixMaxCount(PROJECTS, m)).toBe(2);
});
it('tolerates missing rows without crashing', () => {
const partial: Record<string, Record<string, { count: number; topNames: string[] }>> = {
vestige: { vestige: { count: 3, topNames: [] } },
};
expect(matrixMaxCount(['vestige', 'absent'], partial)).toBe(3);
});
});
// ===========================================================================
// flattenNonZero — mobile fallback feed
// ===========================================================================
describe('flattenNonZero', () => {
it('returns only non-zero pairs, sorted by count descending', () => {
const m = buildTransferMatrix(PROJECTS, PATTERNS);
const rows = flattenNonZero(PROJECTS, m);
// Distinct non-zero cells in fixtures:
// vestige→nullgaze = 2
// vestige→injeranet = 1
// vestige→vestige = 1
// nullgaze→vestige = 1
expect(rows).toHaveLength(4);
expect(rows[0]).toMatchObject({ from: 'vestige', to: 'nullgaze', count: 2 });
// Later rows all tied at 1 — we only verify the leader.
expect(rows.slice(1).every((r) => r.count === 1)).toBe(true);
});
it('returns an empty list when nothing is transferred', () => {
const m = buildTransferMatrix(PROJECTS, []);
expect(flattenNonZero(PROJECTS, m)).toEqual([]);
});
});
// ===========================================================================
// shortProjectName
// ===========================================================================
describe('shortProjectName', () => {
it('passes short names through unchanged', () => {
expect(shortProjectName('vestige')).toBe('vestige');
expect(shortProjectName('')).toBe('');
});
it('keeps names at the 12-char boundary', () => {
expect(shortProjectName('123456789012')).toBe('123456789012');
});
it('truncates longer names to 11 chars + ellipsis', () => {
expect(shortProjectName('1234567890123')).toBe('12345678901…');
expect(shortProjectName('super-long-project-name')).toBe('super-long-…');
});
});

View file

@ -0,0 +1,193 @@
/**
* ReasoningChain pure-logic coverage.
*
* ReasoningChain renders the 8-stage cognitive pipeline. Its rendered output
* is a pure function of a handful of primitive props confidence colours,
* intent-hint selection, and the stage hint resolver. All of that logic
* lives in `reasoning-helpers.ts` and is exercised here without mounting
* Svelte.
*/
import { describe, it, expect } from 'vitest';
import {
confidenceColor,
confidenceLabel,
intentHintFor,
INTENT_HINTS,
CONFIDENCE_EMERALD,
CONFIDENCE_AMBER,
CONFIDENCE_RED,
type IntentKey,
} from '../reasoning-helpers';
// ────────────────────────────────────────────────────────────────
// confidenceColor — the spec-critical boundary table
// ────────────────────────────────────────────────────────────────
describe('confidenceColor — band boundaries (>75 emerald, 40-75 amber, <40 red)', () => {
it.each<[number, string]>([
// Emerald band: strictly greater than 75
[100, CONFIDENCE_EMERALD],
[99.99, CONFIDENCE_EMERALD],
[80, CONFIDENCE_EMERALD],
[76, CONFIDENCE_EMERALD],
[75.01, CONFIDENCE_EMERALD],
// Amber band: 40 <= c <= 75
[75, CONFIDENCE_AMBER], // exactly 75 → amber (page spec: `>75` emerald)
[60, CONFIDENCE_AMBER],
[50, CONFIDENCE_AMBER],
[40.01, CONFIDENCE_AMBER],
[40, CONFIDENCE_AMBER], // exactly 40 → amber (page spec: `>=40` amber)
// Red band: strictly less than 40
[39.99, CONFIDENCE_RED],
[20, CONFIDENCE_RED],
[0.01, CONFIDENCE_RED],
[0, CONFIDENCE_RED],
])('confidence %f → %s', (c, expected) => {
expect(confidenceColor(c)).toBe(expected);
});
it('clamps negative to red (defensive — confidence should never be negative)', () => {
expect(confidenceColor(-10)).toBe(CONFIDENCE_RED);
});
it('over-100 stays emerald (defensive — confidence should never exceed 100)', () => {
expect(confidenceColor(150)).toBe(CONFIDENCE_EMERALD);
});
it('NaN → red (worst-case band)', () => {
expect(confidenceColor(Number.NaN)).toBe(CONFIDENCE_RED);
});
it('is pure — same input yields same output', () => {
for (const c of [0, 39.99, 40, 75, 75.01, 100]) {
expect(confidenceColor(c)).toBe(confidenceColor(c));
}
});
it('never returns an empty string or undefined', () => {
for (const c of [-1, 0, 20, 40, 75, 76, 100, 200, Number.NaN]) {
const colour = confidenceColor(c);
expect(typeof colour).toBe('string');
expect(colour.length).toBeGreaterThan(0);
}
});
});
describe('confidenceLabel — human text per band', () => {
it.each<[number, string]>([
[100, 'HIGH CONFIDENCE'],
[76, 'HIGH CONFIDENCE'],
[75.01, 'HIGH CONFIDENCE'],
[75, 'MIXED SIGNAL'],
[60, 'MIXED SIGNAL'],
[40, 'MIXED SIGNAL'],
[39.99, 'LOW CONFIDENCE'],
[0, 'LOW CONFIDENCE'],
])('confidence %f → %s', (c, expected) => {
expect(confidenceLabel(c)).toBe(expected);
});
it('NaN → LOW CONFIDENCE (safe default)', () => {
expect(confidenceLabel(Number.NaN)).toBe('LOW CONFIDENCE');
});
it('agrees with confidenceColor across the spec boundary sweep', () => {
// Sanity: if the label is HIGH, the colour must be emerald, etc.
const cases: Array<[number, string, string]> = [
[100, 'HIGH CONFIDENCE', CONFIDENCE_EMERALD],
[76, 'HIGH CONFIDENCE', CONFIDENCE_EMERALD],
[75, 'MIXED SIGNAL', CONFIDENCE_AMBER],
[40, 'MIXED SIGNAL', CONFIDENCE_AMBER],
[39.99, 'LOW CONFIDENCE', CONFIDENCE_RED],
[0, 'LOW CONFIDENCE', CONFIDENCE_RED],
];
for (const [c, label, colour] of cases) {
expect(confidenceLabel(c)).toBe(label);
expect(confidenceColor(c)).toBe(colour);
}
});
});
// ────────────────────────────────────────────────────────────────
// Intent classification — visual hint mapping
// ────────────────────────────────────────────────────────────────
describe('INTENT_HINTS — one hint per deep_reference intent', () => {
const intents: IntentKey[] = [
'FactCheck',
'Timeline',
'RootCause',
'Comparison',
'Synthesis',
];
it('defines a hint for every intent the backend emits', () => {
for (const i of intents) {
expect(INTENT_HINTS[i]).toBeDefined();
}
});
it.each(intents)('%s hint has label + icon + description', (i) => {
const hint = INTENT_HINTS[i];
expect(hint.label).toBe(i); // label doubles as canonical id
expect(hint.icon.length).toBeGreaterThan(0);
expect(hint.description.length).toBeGreaterThan(0);
});
it('icons are unique across intents (so the eye can distinguish them)', () => {
const icons = intents.map((i) => INTENT_HINTS[i].icon);
expect(new Set(icons).size).toBe(intents.length);
});
it('descriptions are distinct across intents', () => {
const descs = intents.map((i) => INTENT_HINTS[i].description);
expect(new Set(descs).size).toBe(intents.length);
});
});
describe('intentHintFor — lookup with safe fallback', () => {
it('returns the exact entry for a known intent', () => {
expect(intentHintFor('FactCheck')).toBe(INTENT_HINTS.FactCheck);
expect(intentHintFor('Timeline')).toBe(INTENT_HINTS.Timeline);
expect(intentHintFor('RootCause')).toBe(INTENT_HINTS.RootCause);
expect(intentHintFor('Comparison')).toBe(INTENT_HINTS.Comparison);
expect(intentHintFor('Synthesis')).toBe(INTENT_HINTS.Synthesis);
});
it('falls back to Synthesis for unknown intent (most generic classification)', () => {
expect(intentHintFor('Prediction')).toBe(INTENT_HINTS.Synthesis);
expect(intentHintFor('nonsense')).toBe(INTENT_HINTS.Synthesis);
});
it('falls back to Synthesis for null / undefined / empty string', () => {
expect(intentHintFor(null)).toBe(INTENT_HINTS.Synthesis);
expect(intentHintFor(undefined)).toBe(INTENT_HINTS.Synthesis);
expect(intentHintFor('')).toBe(INTENT_HINTS.Synthesis);
});
it('is case-sensitive — backend emits Title-case strings and we honour that', () => {
// If case-folding becomes desirable, this test will force the
// change to be explicit rather than accidental.
expect(intentHintFor('factcheck')).toBe(INTENT_HINTS.Synthesis);
expect(intentHintFor('FACTCHECK')).toBe(INTENT_HINTS.Synthesis);
});
});
// ────────────────────────────────────────────────────────────────
// Stage-count invariant — the component renders exactly 8 stages
// ────────────────────────────────────────────────────────────────
describe('Cognitive pipeline shape', () => {
it('confidence colour constants are all distinct hex strings', () => {
const set = new Set([
CONFIDENCE_EMERALD.toLowerCase(),
CONFIDENCE_AMBER.toLowerCase(),
CONFIDENCE_RED.toLowerCase(),
]);
expect(set.size).toBe(3);
for (const c of set) {
expect(c).toMatch(/^#[0-9a-f]{6}$/);
}
});
});

View file

@ -0,0 +1,237 @@
/**
* activation-helpers Pure logic for the Spreading Activation Live View.
*
* Extracted from ActivationNetwork.svelte + activation/+page.svelte so the
* decay / geometry / event-filtering rules can be exercised in the Vitest
* `node` environment without jsdom. Every helper in this module is a pure
* function of its inputs; no DOM, no timers, no Svelte runes.
*
* The constants in this module are the single source of truth the Svelte
* component re-exports / re-uses them rather than hard-coding its own.
*
* References
* ----------
* - Collins & Loftus 1975 spreading activation with exponential decay
* - Anderson 1983 (ACT-R) activation threshold for availability
*/
import { NODE_TYPE_COLORS } from '$types';
import type { VestigeEvent } from '$types';
/** Per-tick multiplicative decay factor (Collins & Loftus 1975). */
export const DECAY = 0.93;
/** Activation below this floor is invisible / garbage-collected. */
export const MIN_VISIBLE = 0.05;
/** Fallback node colour when NODE_TYPE_COLORS has no entry for the type. */
export const FALLBACK_COLOR = '#8B95A5';
/** Source node colour (synapse-glow). Distinct from any node-type colour. */
export const SOURCE_COLOR = '#818cf8';
/** Radial spacing between concentric rings (px). */
export const RING_GAP = 140;
/** Max neighbours that fit on ring 1 before spilling to ring 2. */
export const RING_1_CAPACITY = 8;
/** Edge draw stagger — frames of delay per rank inside a ring. */
export const STAGGER_PER_RANK = 4;
/** Extra stagger added to ring-2 edges so they light up after ring 1. */
export const STAGGER_RING_2_BONUS = 12;
// ---------------------------------------------------------------------------
// Decay math
// ---------------------------------------------------------------------------
/**
* Apply a single tick of exponential decay. Clamps negative input to 0 so a
* corrupt state never produces a creeping-positive value on the next tick.
*/
export function applyDecay(activation: number): number {
if (!Number.isFinite(activation) || activation <= 0) return 0;
return activation * DECAY;
}
/**
* Compound decay over N ticks. N < 0 is treated as 0 (no change).
* Equivalent to calling `applyDecay` N times.
*/
export function compoundDecay(activation: number, ticks: number): number {
if (!Number.isFinite(activation) || activation <= 0) return 0;
if (!Number.isFinite(ticks) || ticks <= 0) return activation;
return activation * DECAY ** ticks;
}
/** True if the node's activation is at or above the visibility floor. */
export function isVisible(activation: number): boolean {
if (!Number.isFinite(activation)) return false;
return activation >= MIN_VISIBLE;
}
/**
* How many ticks until `initial` decays below `MIN_VISIBLE`. Useful in tests
* and for sizing animation budgets. Initial <= threshold returns 0.
*/
export function ticksUntilInvisible(initial: number): number {
if (!Number.isFinite(initial) || initial <= MIN_VISIBLE) return 0;
// initial * DECAY^n < MIN_VISIBLE → n > log(MIN_VISIBLE/initial) / log(DECAY)
const n = Math.log(MIN_VISIBLE / initial) / Math.log(DECAY);
return Math.ceil(n);
}
// ---------------------------------------------------------------------------
// Ring placement — concentric circles around a source
// ---------------------------------------------------------------------------
export interface Point {
x: number;
y: number;
}
/**
* Classify a neighbour's 0-indexed rank into a ring number.
* Ranks 0..RING_1_CAPACITY-1 ring 1; rest ring 2.
*/
export function computeRing(rank: number): 1 | 2 {
if (!Number.isFinite(rank) || rank < RING_1_CAPACITY) return 1;
return 2;
}
/**
* Evenly distribute `count` positions on a circle of radius `ring * RING_GAP`
* centred at (cx, cy). `angleOffset` rotates the whole ring so overlapping
* bursts don't perfectly collide. Zero count returns `[]`.
*/
export function ringPositions(
cx: number,
cy: number,
count: number,
ring: number,
angleOffset = 0,
): Point[] {
if (!Number.isFinite(count) || count <= 0) return [];
const radius = RING_GAP * ring;
const positions: Point[] = [];
for (let i = 0; i < count; i++) {
const angle = angleOffset + (i / count) * Math.PI * 2;
positions.push({
x: cx + Math.cos(angle) * radius,
y: cy + Math.sin(angle) * radius,
});
}
return positions;
}
/**
* Given the full neighbour list, produce a flat array of Points ring 1
* first, ring 2 after. The resulting length === neighbours.length.
*/
export function layoutNeighbours(
cx: number,
cy: number,
neighbourCount: number,
angleOffset = 0,
): Point[] {
const ring1 = Math.min(neighbourCount, RING_1_CAPACITY);
const ring2 = Math.max(0, neighbourCount - RING_1_CAPACITY);
return [
...ringPositions(cx, cy, ring1, 1, angleOffset),
...ringPositions(cx, cy, ring2, 2, angleOffset),
];
}
// ---------------------------------------------------------------------------
// Initial activation by rank
// ---------------------------------------------------------------------------
/**
* Seed activation for a neighbour at 0-indexed `rank` given `total`.
* Higher-ranked (earlier) neighbours get stronger initial activation.
* Ring-2 neighbours get a 0.75× ring-factor penalty on top of the rank factor.
* Returns a value in (0, 1].
*/
export function initialActivation(rank: number, total: number): number {
if (!Number.isFinite(total) || total <= 0) return 0;
if (!Number.isFinite(rank) || rank < 0) return 0;
const rankFactor = 1 - (rank / total) * 0.35;
const ringFactor = computeRing(rank) === 1 ? 1 : 0.75;
return Math.min(1, rankFactor * ringFactor);
}
// ---------------------------------------------------------------------------
// Edge stagger
// ---------------------------------------------------------------------------
/**
* Delay (in animation frames) before the edge at rank `i` starts drawing.
* Ring 1 edges light up first, then ring 2 after a bonus delay.
*/
export function edgeStagger(rank: number): number {
if (!Number.isFinite(rank) || rank < 0) return 0;
const r = Math.floor(rank);
const base = r * STAGGER_PER_RANK;
return computeRing(r) === 1 ? base : base + STAGGER_RING_2_BONUS;
}
// ---------------------------------------------------------------------------
// Color mapping
// ---------------------------------------------------------------------------
/**
* Colour for a node on the activation canvas.
* - source nodes always use SOURCE_COLOR (synapse-glow)
* - known node types use NODE_TYPE_COLORS
* - unknown node types fall back to FALLBACK_COLOR (soft steel)
*/
export function activationColor(
nodeType: string | null | undefined,
isSource: boolean,
): string {
if (isSource) return SOURCE_COLOR;
if (!nodeType) return FALLBACK_COLOR;
return NODE_TYPE_COLORS[nodeType] ?? FALLBACK_COLOR;
}
// ---------------------------------------------------------------------------
// Event-feed filtering — "only fire on NEW ActivationSpread events"
// ---------------------------------------------------------------------------
export interface SpreadPayload {
source_id: string;
target_ids: string[];
}
/**
* Extract ActivationSpread payloads from a websocket event feed. The feed
* is prepended (newest at index 0, oldest at the end). Stop as soon as we
* hit the reference of `lastSeen` events at or past that point were
* already processed by a prior tick.
*
* Returned payloads are in OLDEST-FIRST order so downstream callers can
* fire them in the same narrative order they occurred.
*
* Payloads missing required fields are silently skipped.
*/
export function filterNewSpreadEvents(
feed: readonly VestigeEvent[],
lastSeen: VestigeEvent | null,
): SpreadPayload[] {
if (!feed || feed.length === 0) return [];
const fresh: SpreadPayload[] = [];
for (const ev of feed) {
if (ev === lastSeen) break;
if (ev.type !== 'ActivationSpread') continue;
const data = ev.data as { source_id?: unknown; target_ids?: unknown };
if (typeof data.source_id !== 'string') continue;
if (!Array.isArray(data.target_ids)) continue;
const targets = data.target_ids.filter(
(t): t is string => typeof t === 'string',
);
if (targets.length === 0) continue;
fresh.push({ source_id: data.source_id, target_ids: targets });
}
// Reverse so oldest-first.
return fresh.reverse();
}

View file

@ -0,0 +1,293 @@
/**
* Pure helpers for MemoryAuditTrail.
*
* Extracted for isolated unit testing in a Node (vitest) environment
* no DOM, no Svelte runtime, no fetch. Every function in this module is
* deterministic given its inputs.
*/
export type AuditAction =
| 'created'
| 'accessed'
| 'promoted'
| 'demoted'
| 'edited'
| 'suppressed'
| 'dreamed'
| 'reconsolidated';
export interface AuditEvent {
action: AuditAction;
timestamp: string; // ISO
old_value?: number;
new_value?: number;
reason?: string;
triggered_by?: string;
}
export type MarkerKind =
| 'dot'
| 'arrow-up'
| 'arrow-down'
| 'pencil'
| 'x'
| 'star'
| 'circle-arrow'
| 'ring';
export interface Meta {
label: string;
color: string; // hex for dot + glow
glyph: string; // optional inline symbol
kind: MarkerKind;
}
/**
* Event type visual metadata. Each action maps to a UNIQUE marker `kind`
* so the 8 event types are visually distinguishable without relying on the
* colour palette alone (accessibility).
*/
export const META: Record<AuditAction, Meta> = {
created: { label: 'Created', color: '#10b981', glyph: '', kind: 'ring' },
accessed: { label: 'Accessed', color: '#3b82f6', glyph: '', kind: 'dot' },
promoted: { label: 'Promoted', color: '#10b981', glyph: '', kind: 'arrow-up' },
demoted: { label: 'Demoted', color: '#f59e0b', glyph: '', kind: 'arrow-down' },
edited: { label: 'Edited', color: '#facc15', glyph: '', kind: 'pencil' },
suppressed: { label: 'Suppressed', color: '#a855f7', glyph: '', kind: 'x' },
dreamed: { label: 'Dreamed', color: '#c084fc', glyph: '', kind: 'star' },
reconsolidated: { label: 'Reconsolidated', color: '#ec4899', glyph: '', kind: 'circle-arrow' }
};
export const VISIBLE_LIMIT = 15;
/**
* All 8 `AuditAction` values, in the canonical order. Used both by the
* event generator (`actionPool`) and by tests that verify uniqueness of
* the marker mapping.
*/
export const ALL_ACTIONS: readonly AuditAction[] = [
'created',
'accessed',
'promoted',
'demoted',
'edited',
'suppressed',
'dreamed',
'reconsolidated'
] as const;
/**
* Hash a string id into a 32-bit unsigned seed. Stable across runs.
*/
export function hashSeed(id: string): number {
let seed = 0;
for (let i = 0; i < id.length; i++) seed = (seed * 31 + id.charCodeAt(i)) >>> 0;
return seed;
}
/**
* Linear congruential PRNG bound to a mutable seed. Returns a function
* that yields floats in `[0, 1)` critically, NEVER 1.0, so callers
* can safely use `Math.floor(rand() * arr.length)` without off-by-one.
*/
export function makeRand(initialSeed: number): () => number {
let seed = initialSeed >>> 0;
return () => {
seed = (seed * 1664525 + 1013904223) >>> 0;
// Divide by 2^32, not 2^32 - 1 — the latter can yield exactly 1.0
// when seed is UINT32_MAX, breaking array-index math.
return seed / 0x100000000;
};
}
/**
* Deterministic mock audit-trail generator. Same `memoryId` + `nowMs`
* ALWAYS yields the same event sequence (critical for snapshot stability
* and for tests). An empty `memoryId` yields no events the audit trail
* panel should never invent history for a non-existent memory.
*
* `countOverride` lets tests force a specific number of events (e.g.
* to cross the 15-event visibility threshold, which the default range
* 8-15 cannot do).
*/
export function generateMockAuditTrail(
memoryId: string,
nowMs: number = Date.now(),
countOverride?: number
): AuditEvent[] {
if (!memoryId) return [];
const rand = makeRand(hashSeed(memoryId));
const count = countOverride ?? 8 + Math.floor(rand() * 8); // default 8-15 events
if (count <= 0) return [];
const out: AuditEvent[] = [];
const createdAt = nowMs - (14 + rand() * 21) * 86_400_000; // 14-35 days ago
out.push({
action: 'created',
timestamp: new Date(createdAt).toISOString(),
reason: 'smart_ingest · prediction-error gate opened',
triggered_by: 'smart_ingest'
});
let t = createdAt;
let retention = 0.5 + rand() * 0.2;
const actionPool: AuditAction[] = [
'accessed',
'accessed',
'accessed',
'accessed',
'promoted',
'demoted',
'edited',
'dreamed',
'reconsolidated',
'suppressed'
];
for (let i = 1; i < count; i++) {
t += rand() * 5 * 86_400_000 + 3_600_000; // 1h-5d between events
const action = actionPool[Math.floor(rand() * actionPool.length)];
const ev: AuditEvent = { action, timestamp: new Date(t).toISOString() };
switch (action) {
case 'accessed': {
const old = retention;
retention = Math.min(1, retention + rand() * 0.04 + 0.01);
ev.old_value = old;
ev.new_value = retention;
ev.triggered_by = rand() > 0.5 ? 'search' : 'deep_reference';
break;
}
case 'promoted': {
const old = retention;
retention = Math.min(1, retention + 0.1);
ev.old_value = old;
ev.new_value = retention;
ev.reason = 'confirmed helpful by user';
ev.triggered_by = 'memory(action=promote)';
break;
}
case 'demoted': {
const old = retention;
retention = Math.max(0, retention - 0.15);
ev.old_value = old;
ev.new_value = retention;
ev.reason = 'user flagged as outdated';
ev.triggered_by = 'memory(action=demote)';
break;
}
case 'edited': {
ev.reason = 'content refined, FSRS state preserved';
ev.triggered_by = 'memory(action=edit)';
break;
}
case 'suppressed': {
const old = retention;
retention = Math.max(0, retention - 0.08);
ev.old_value = old;
ev.new_value = retention;
ev.reason = 'top-down inhibition (Anderson 2025)';
ev.triggered_by = 'suppress(dashboard)';
break;
}
case 'dreamed': {
const old = retention;
retention = Math.min(1, retention + 0.05);
ev.old_value = old;
ev.new_value = retention;
ev.reason = 'replayed during dream consolidation';
ev.triggered_by = 'dream()';
break;
}
case 'reconsolidated': {
ev.reason = 'edited within 5-min labile window (Nader)';
ev.triggered_by = 'reconsolidation-manager';
break;
}
case 'created':
// Created is only emitted once, as the first event. If the pool
// ever yields it again, treat it as a no-op access marker with
// no retention change — defensive, not expected.
ev.triggered_by = 'smart_ingest';
break;
}
out.push(ev);
}
// Newest first for display.
return out.reverse();
}
/**
* Humanised relative time. Uses supplied `nowMs` for deterministic tests;
* defaults to `Date.now()` in production.
*
* Boundaries (strictly `<`, so 60s flips to "1m", 60m flips to "1h", etc.):
* <60s "Ns ago"
* <60m "Nm ago"
* <24h "Nh ago"
* <30d "Nd ago"
* <12mo "Nmo ago"
* else "Ny ago"
*
* Future timestamps (nowMs < then) clamp to "0s ago" rather than returning
* a negative string the audit trail is a past-only view.
*/
export function relativeTime(iso: string, nowMs: number = Date.now()): string {
const then = new Date(iso).getTime();
const diff = Math.max(0, nowMs - then);
const s = Math.floor(diff / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 30) return `${d}d ago`;
const mo = Math.floor(d / 30);
if (mo < 12) return `${mo}mo ago`;
const y = Math.floor(mo / 12);
return `${y}y ago`;
}
/**
* Retention delta formatter. Behaviour:
* (undef, undef) null no retention movement on this event
* (undef, 0.72) "set 0.72" initial value, no prior state
* (0.50, undef) "was 0.50" retention cleared (rare)
* (0.50, 0.72) "0.50 → 0.72"
*
* The `retention ` prefix is left to the caller so tests can compare the
* core formatted value precisely.
*/
export function formatRetentionDelta(
oldValue: number | undefined,
newValue: number | undefined
): string | null {
const hasOld = typeof oldValue === 'number' && Number.isFinite(oldValue);
const hasNew = typeof newValue === 'number' && Number.isFinite(newValue);
if (!hasOld && !hasNew) return null;
if (!hasOld && hasNew) return `set ${newValue!.toFixed(2)}`;
if (hasOld && !hasNew) return `was ${oldValue!.toFixed(2)}`;
return `${oldValue!.toFixed(2)}${newValue!.toFixed(2)}`;
}
/**
* Split an event list into (visible, hiddenCount) per the 15-event cap.
* Exactly 15 events no toggle (hiddenCount = 0). 16+ toggle.
*/
export function splitVisible(
events: AuditEvent[],
showAll: boolean
): { visible: AuditEvent[]; hiddenCount: number } {
if (showAll || events.length <= VISIBLE_LIMIT) {
return { visible: events, hiddenCount: Math.max(0, events.length - VISIBLE_LIMIT) };
}
return {
visible: events.slice(0, VISIBLE_LIMIT),
hiddenCount: events.length - VISIBLE_LIMIT
};
}

View file

@ -0,0 +1,192 @@
/**
* Pure helpers for AmbientAwarenessStrip.svelte.
*
* Extracted so the time-window, event-scan, and timestamp-parsing logic can
* be unit tested in the vitest `node` environment without jsdom, Svelte
* rendering, or fake timers bleeding into runes.
*
* Contracts
* ---------
* - `parseEventTimestamp`: handles (a) numeric ms (>1e12), (b) numeric seconds
* (<=1e12), (c) ISO-8601 string, (d) invalid/absent null.
* - `bucketizeActivity`: given ms timestamps + `now`, returns 10 counts for a
* 5-min trailing window. Bucket 0 = oldest 30s, bucket 9 = newest 30s.
* Events outside [now-5m, now] are dropped (clock skew).
* - `findRecentDream`: returns the newest-indexed (feed is newest-first)
* DreamCompleted whose parsed timestamp is within 24h, else null. If the
* timestamp is unparseable, `now` is used as the fallback (matches the
* component's behavior).
* - `isDreaming`: a DreamStarted within the last 5 min NOT followed by a
* newer DreamCompleted. Mirrors the component's derived block exactly.
* - `hasRecentSuppression`: any MemorySuppressed event with a parsed
* timestamp within `thresholdMs` of `now`. Feed is assumed newest-first
* we break as soon as we pass the threshold, matching component behavior.
*
* All helpers are null-safe and treat unparseable timestamps consistently
* (fall back to `now`, matching the on-screen "something just happened" feel).
*/
export interface EventLike {
type: string;
data?: Record<string, unknown>;
}
/**
* Parse a VestigeEvent timestamp, checking `data.timestamp`, then `data.at`,
* then `data.occurred_at`. Supports ms-since-epoch numbers, seconds-since-epoch
* numbers, and ISO-8601 strings. Returns null for absent / invalid input.
*
* Numeric heuristic: values > 1e12 are treated as ms (2001+), values <= 1e12
* are treated as seconds. `1e12 ms` Sept 2001, so any real ms timestamp
* lands safely on the "ms" side.
*/
export function parseEventTimestamp(event: EventLike): number | null {
const d = event.data;
if (!d || typeof d !== 'object') return null;
const raw =
(d.timestamp as string | number | undefined) ??
(d.at as string | number | undefined) ??
(d.occurred_at as string | number | undefined);
if (raw === undefined || raw === null) return null;
if (typeof raw === 'number') {
if (!Number.isFinite(raw)) return null;
return raw > 1e12 ? raw : raw * 1000;
}
if (typeof raw !== 'string') return null;
const ms = Date.parse(raw);
return Number.isFinite(ms) ? ms : null;
}
export const ACTIVITY_BUCKET_COUNT = 10;
export const ACTIVITY_BUCKET_MS = 30_000;
export const ACTIVITY_WINDOW_MS = ACTIVITY_BUCKET_COUNT * ACTIVITY_BUCKET_MS;
export interface ActivityBucket {
count: number;
ratio: number;
}
/**
* Bucket event timestamps into 10 × 30s buckets for a 5-min trailing window.
* Events with `type === 'Heartbeat'` are skipped (noise). Events whose
* timestamp is out of window (clock skew / pre-history) are dropped.
*
* Returned `ratio` is `count / max(1, maxBucketCount)` so a sparkline with
* zero events has all-zero ratios (no division by zero) and a sparkline with
* a single spike peaks at 1.0.
*/
export function bucketizeActivity(
events: EventLike[],
nowMs: number,
): ActivityBucket[] {
const start = nowMs - ACTIVITY_WINDOW_MS;
const counts = new Array<number>(ACTIVITY_BUCKET_COUNT).fill(0);
for (const e of events) {
if (e.type === 'Heartbeat') continue;
const t = parseEventTimestamp(e);
if (t === null || t < start || t > nowMs) continue;
const idx = Math.min(
ACTIVITY_BUCKET_COUNT - 1,
Math.floor((t - start) / ACTIVITY_BUCKET_MS),
);
counts[idx] += 1;
}
const max = Math.max(1, ...counts);
return counts.map((count) => ({ count, ratio: count / max }));
}
/**
* Find the most recent DreamCompleted within 24h of `nowMs`.
* Feed is assumed newest-first we return the FIRST match.
* Unparseable timestamps fall back to `nowMs` (matches component behavior).
*/
export function findRecentDream(
events: EventLike[],
nowMs: number,
): EventLike | null {
const dayAgo = nowMs - 24 * 60 * 60 * 1000;
for (const e of events) {
if (e.type !== 'DreamCompleted') continue;
const t = parseEventTimestamp(e) ?? nowMs;
if (t >= dayAgo) return e;
return null; // newest-first: older ones definitely won't match
}
return null;
}
/**
* Extract `insights_generated` / `insightsGenerated` from a DreamCompleted
* event payload. Returns null if missing or non-numeric.
*/
export function dreamInsightsCount(event: EventLike | null): number | null {
if (!event || !event.data) return null;
const d = event.data;
const raw =
typeof d.insights_generated === 'number'
? d.insights_generated
: typeof d.insightsGenerated === 'number'
? d.insightsGenerated
: null;
return raw !== null && Number.isFinite(raw) ? raw : null;
}
/**
* A Dream is in flight if the newest DreamStarted is within 5 min of `nowMs`
* AND there is no DreamCompleted with a timestamp >= that DreamStarted.
*
* Feed is assumed newest-first. We scan once, grabbing the first Started and
* first Completed, then compare matching the component's derived block.
*/
export function isDreaming(events: EventLike[], nowMs: number): boolean {
let started: EventLike | null = null;
let completed: EventLike | null = null;
for (const e of events) {
if (!started && e.type === 'DreamStarted') started = e;
if (!completed && e.type === 'DreamCompleted') completed = e;
if (started && completed) break;
}
if (!started) return false;
const startedAt = parseEventTimestamp(started) ?? nowMs;
const fiveMinAgo = nowMs - 5 * 60 * 1000;
if (startedAt < fiveMinAgo) return false;
if (!completed) return true;
const completedAt = parseEventTimestamp(completed) ?? nowMs;
return completedAt < startedAt;
}
/**
* Format an "ago" duration compactly. Pure and deterministic.
* 0-59s "Ns ago", 60-3599s "Nm ago", <24h "Nh ago", else "Nd ago".
* Negative input is clamped to 0.
*/
export function formatAgo(ms: number): string {
const clamped = Math.max(0, ms);
const s = Math.floor(clamped / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
/**
* True if any MemorySuppressed event lies within `thresholdMs` of `nowMs`.
* Feed assumed newest-first break as soon as we encounter one OUTSIDE
* the window (all older ones are definitely older). Unparseable timestamps
* fall back to `nowMs` so the flash fires matches component behavior.
*/
export function hasRecentSuppression(
events: EventLike[],
nowMs: number,
thresholdMs: number = 10_000,
): boolean {
const cutoff = nowMs - thresholdMs;
for (const e of events) {
if (e.type !== 'MemorySuppressed') continue;
const t = parseEventTimestamp(e) ?? nowMs;
if (t >= cutoff) return true;
return false; // newest-first: older ones definitely won't match
}
return false;
}

View file

@ -0,0 +1,210 @@
/**
* contradiction-helpers Pure logic for the Contradiction Constellation UI.
*
* Extracted from ContradictionArcs.svelte + contradictions/+page.svelte so
* the math and classification live in one place and can be tested in the
* vitest `node` environment without jsdom / Svelte harnessing.
*
* Contracts
* ---------
* - Severity thresholds are STRICTLY exclusive: similarity > 0.7 strong,
* similarity > 0.5 moderate, else mild. The boundary values 0.5 and
* 0.7 therefore fall into the LOWER band on purpose (so a similarity of
* exactly 0.7 is 'moderate', not 'strong').
* - Node type palette has 8 known types; anything else including
* `undefined`, `null`, empty string, or a typo falls back to violet
* (#8b5cf6), matching the `concept` fallback tone used elsewhere.
* - Pair opacity is a trinary rule: no focus 1, focused match 1,
* focused non-match 0.12. `null` and `undefined` both mean "no focus".
* - Trust is defined on [0,1]; `nodeRadius` clamps out-of-range values so
* a negative trust can't produce a sub-zero radius and a >1 trust can't
* balloon past the design maximum (14px).
* - `uniqueMemoryCount` unions memory_a_id + memory_b_id across the whole
* pair list; duplicated pairs do not double-count.
*/
/** Shape used by the constellation. Mirrors ContradictionArcs.Contradiction. */
export interface ContradictionLike {
memory_a_id: string;
memory_b_id: string;
}
// ---------------------------------------------------------------------------
// Severity — similarity → colour + label.
// ---------------------------------------------------------------------------
export type SeverityLabel = 'strong' | 'moderate' | 'mild';
/** Strong threshold. Similarity STRICTLY above this is red. */
export const SEVERITY_STRONG_THRESHOLD = 0.7;
/** Moderate threshold. Similarity STRICTLY above this (and <= 0.7) is amber. */
export const SEVERITY_MODERATE_THRESHOLD = 0.5;
export const SEVERITY_STRONG_COLOR = '#ef4444';
export const SEVERITY_MODERATE_COLOR = '#f59e0b';
export const SEVERITY_MILD_COLOR = '#fde047';
/**
* Severity colour by similarity. Boundaries at 0.5 and 0.7 fall into the
* LOWER band (strictly-greater-than comparison).
*
* sim > 0.7 '#ef4444' (strong / red)
* sim > 0.5 '#f59e0b' (moderate / amber)
* otherwise '#fde047' (mild / yellow)
*/
export function severityColor(sim: number): string {
if (sim > SEVERITY_STRONG_THRESHOLD) return SEVERITY_STRONG_COLOR;
if (sim > SEVERITY_MODERATE_THRESHOLD) return SEVERITY_MODERATE_COLOR;
return SEVERITY_MILD_COLOR;
}
/** Severity label by similarity. Same thresholds as severityColor. */
export function severityLabel(sim: number): SeverityLabel {
if (sim > SEVERITY_STRONG_THRESHOLD) return 'strong';
if (sim > SEVERITY_MODERATE_THRESHOLD) return 'moderate';
return 'mild';
}
// ---------------------------------------------------------------------------
// Node type palette.
// ---------------------------------------------------------------------------
/** Fallback colour used when a memory's node_type is missing or unknown. */
export const NODE_COLOR_FALLBACK = '#8b5cf6';
/** Canonical palette for the 8 known node types. */
export const NODE_COLORS: Record<string, string> = {
fact: '#3b82f6',
concept: '#8b5cf6',
event: '#f59e0b',
person: '#10b981',
place: '#06b6d4',
note: '#6b7280',
pattern: '#ec4899',
decision: '#ef4444',
};
/** Canonical list of known types (stable order — matches palette object). */
export const KNOWN_NODE_TYPES = Object.freeze([
'fact',
'concept',
'event',
'person',
'place',
'note',
'pattern',
'decision',
]) as readonly string[];
/**
* Map a (possibly undefined) node_type to a colour. Unknown / missing /
* empty / null strings fall back to violet (#8b5cf6).
*/
export function nodeColor(t?: string | null): string {
if (!t) return NODE_COLOR_FALLBACK;
return NODE_COLORS[t] ?? NODE_COLOR_FALLBACK;
}
// ---------------------------------------------------------------------------
// Trust → node radius.
// ---------------------------------------------------------------------------
/** Minimum circle radius at trust=0. */
export const NODE_RADIUS_MIN = 5;
/** Additional radius at trust=1. `r = 5 + trust * 9`, so r ∈ [5, 14]. */
export const NODE_RADIUS_RANGE = 9;
/**
* Clamp `trust` to [0,1] before mapping to a radius so a bad FSRS value
* can't produce a sub-zero or oversize node. Non-finite values collapse
* to 0 (smallest radius visually suppresses suspicious data).
*/
export function nodeRadius(trust: number): number {
if (!Number.isFinite(trust)) return NODE_RADIUS_MIN;
const t = trust < 0 ? 0 : trust > 1 ? 1 : trust;
return NODE_RADIUS_MIN + t * NODE_RADIUS_RANGE;
}
/** Clamp trust to [0,1]. NaN/Infinity/undefined → 0. */
export function clampTrust(trust: number | null | undefined): number {
if (trust === null || trust === undefined || !Number.isFinite(trust)) return 0;
if (trust < 0) return 0;
if (trust > 1) return 1;
return trust;
}
// ---------------------------------------------------------------------------
// Focus / pair opacity.
// ---------------------------------------------------------------------------
/** Opacity applied to a non-focused pair when any pair is focused. */
export const UNFOCUSED_OPACITY = 0.12;
/**
* Opacity for a pair given the current focus state.
*
* focus = null/undefined 1 (nothing dimmed)
* focus === pairIndex 1 (the focused pair is fully lit)
* focus !== pairIndex 0.12 (dimmed)
*
* A focus index that doesn't match any rendered pair simply dims everything.
* That's the intended "silent no-op" for a stale focusedPairIndex.
*/
export function pairOpacity(pairIndex: number, focusedPairIndex: number | null | undefined): number {
if (focusedPairIndex === null || focusedPairIndex === undefined) return 1;
return focusedPairIndex === pairIndex ? 1 : UNFOCUSED_OPACITY;
}
// ---------------------------------------------------------------------------
// Text truncation.
// ---------------------------------------------------------------------------
/**
* Truncate a string to `max` characters with an ellipsis at the end.
* Shorter-or-equal strings return unchanged. Empty strings return unchanged.
* Non-string inputs collapse to '' rather than crashing.
*
* The ellipsis counts toward the length budget, so the cut-off content is
* `max - 1` characters, matching the component's inline truncate() helper.
*/
export function truncate(s: string | null | undefined, max = 60): string {
if (s === null || s === undefined) return '';
if (typeof s !== 'string') return '';
if (max <= 0) return '';
if (s.length <= max) return s;
return s.slice(0, max - 1) + '…';
}
// ---------------------------------------------------------------------------
// Stats.
// ---------------------------------------------------------------------------
/**
* Count unique memory IDs across a list of contradiction pairs. Each pair
* contributes memory_a_id and memory_b_id. Duplicates (e.g. one memory that
* appears in multiple conflicts) are counted once.
*/
export function uniqueMemoryCount(pairs: readonly ContradictionLike[]): number {
if (!pairs || pairs.length === 0) return 0;
const set = new Set<string>();
for (const p of pairs) {
if (p.memory_a_id) set.add(p.memory_a_id);
if (p.memory_b_id) set.add(p.memory_b_id);
}
return set.size;
}
/**
* Average absolute trust delta across pairs. Returns 0 on empty input so
* the UI can render `0.00` instead of `NaN`.
*/
export function avgTrustDelta(
pairs: readonly { trust_a: number; trust_b: number }[],
): number {
if (!pairs || pairs.length === 0) return 0;
let sum = 0;
for (const p of pairs) {
sum += Math.abs((p.trust_a ?? 0) - (p.trust_b ?? 0));
}
return sum / pairs.length;
}

View file

@ -0,0 +1,155 @@
/**
* dream-helpers Pure logic for Dream Cinema UI.
*
* Extracted so we can test it without jsdom / Svelte component harnessing.
* The Vitest setup for this package runs in a Node environment; every helper
* in this module is a pure function of its inputs, so it can be exercised
* directly in `__tests__/*.test.ts` alongside the graph helpers.
*/
/** Stage 1..5 of the 5-phase consolidation cycle. */
export const STAGE_COUNT = 5 as const;
/** Display names for each stage index (1-indexed). */
export const STAGE_NAMES = [
'Replay',
'Cross-reference',
'Strengthen',
'Prune',
'Transfer',
] as const;
export type StageIndex = 1 | 2 | 3 | 4 | 5;
/**
* Clamp an arbitrary integer to the valid 1..5 stage range. Accepts any
* number (NaN, Infinity, negatives, floats) and always returns an integer
* in [1,5]. NaN and non-finite values fall back to 1 this matches the
* "start at stage 1" behaviour on a fresh dream.
*/
export function clampStage(n: number): StageIndex {
if (!Number.isFinite(n)) return 1;
const i = Math.floor(n);
if (i < 1) return 1;
if (i > STAGE_COUNT) return STAGE_COUNT;
return i as StageIndex;
}
/**
* Get the human-readable stage name for a (possibly invalid) stage number.
* Uses `clampStage`, so out-of-range inputs return the nearest valid name.
*/
export function stageName(n: number): string {
return STAGE_NAMES[clampStage(n) - 1];
}
// ---------------------------------------------------------------------------
// Novelty classification — drives the gold-glow / muted styling on insight
// cards. Thresholds are STRICTLY exclusive so `0.3` and `0.7` map to the
// neutral band on purpose. See DreamInsightCard.svelte.
// ---------------------------------------------------------------------------
export type NoveltyBand = 'high' | 'neutral' | 'low';
/** Upper bound for the muted "low novelty" band. Values BELOW this are low. */
export const LOW_NOVELTY_THRESHOLD = 0.3;
/** Lower bound for the gold "high novelty" band. Values ABOVE this are high. */
export const HIGH_NOVELTY_THRESHOLD = 0.7;
/**
* Classify a novelty score into one of 3 visual bands.
*
* Thresholds are exclusive on both sides:
* novelty > 0.7 'high' (gold glow)
* novelty < 0.3 'low' (muted / desaturated)
* otherwise 'neutral'
*
* `null` / `undefined` / `NaN` collapse to 0 'low'.
*/
export function noveltyBand(novelty: number | null | undefined): NoveltyBand {
const n = clamp01(novelty);
if (n > HIGH_NOVELTY_THRESHOLD) return 'high';
if (n < LOW_NOVELTY_THRESHOLD) return 'low';
return 'neutral';
}
/** Clamp a value into [0,1]. `null`/`undefined`/`NaN` → 0. */
export function clamp01(n: number | null | undefined): number {
if (n === null || n === undefined || !Number.isFinite(n)) return 0;
if (n < 0) return 0;
if (n > 1) return 1;
return n;
}
// ---------------------------------------------------------------------------
// Formatting helpers — mirror what the page + card render. Keeping these
// pure lets us test the exact output strings without rendering Svelte.
// ---------------------------------------------------------------------------
/**
* Format a millisecond duration as a human-readable string.
* < 1000ms "{n}ms" (e.g. "0ms", "500ms")
* 1000ms "{n.nn}s" (e.g. "1.50s", "15.00s")
* Negative / NaN values collapse to "0ms".
*/
export function formatDurationMs(ms: number | null | undefined): string {
if (ms === null || ms === undefined || !Number.isFinite(ms) || ms < 0) {
return '0ms';
}
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
/**
* Format a 0..1 confidence as a whole-percent string ("0%", "50%", "100%").
* Values outside [0,1] clamp first. Uses `Math.round` so 0.505 "51%".
*/
export function formatConfidencePct(confidence: number | null | undefined): string {
const c = clamp01(confidence);
return `${Math.round(c * 100)}%`;
}
// ---------------------------------------------------------------------------
// Source memory link formatting.
// ---------------------------------------------------------------------------
/**
* Build the href for a source memory link. We keep this behind a helper so
* the route format is tested in one place. `base` corresponds to SvelteKit's
* `$app/paths` base (may be ""). Invalid IDs still produce a URL route
* handling is the page's responsibility, not ours.
*/
export function sourceMemoryHref(id: string, base = ''): string {
return `${base}/memories/${id}`;
}
/**
* Return the first N source memory IDs from an insight's `sourceMemories`
* array, safely handling null / undefined / empty. Default N = 2, matching
* the card's "first 2 links" behaviour.
*/
export function firstSourceIds(
sources: readonly string[] | null | undefined,
n = 2,
): string[] {
if (!sources || sources.length === 0) return [];
return sources.slice(0, Math.max(0, n));
}
/** Count of sources beyond the first N. Used for the "(+N)" suffix. */
export function extraSourceCount(
sources: readonly string[] | null | undefined,
shown = 2,
): number {
if (!sources) return 0;
return Math.max(0, sources.length - shown);
}
/**
* Truncate a memory UUID for display on the chip. Matches the previous
* inline `shortId` logic: first 8 chars, or the whole string if shorter.
*/
export function shortMemoryId(id: string): string {
if (!id) return '';
return id.length > 8 ? id.slice(0, 8) : id;
}

View file

@ -0,0 +1,149 @@
/**
* Pure helpers for the Memory Hygiene / Duplicate Detection UI.
*
* Extracted from DuplicateCluster.svelte + duplicates/+page.svelte so the
* logic can be unit tested in the vitest `node` environment without jsdom.
*
* Contracts
* ---------
* - `similarityBand`: fixed thresholds at 0.92 (near-identical) and 0.80
* (strong). Boundary values MATCH the higher band (>= semantics).
* - `pickWinner`: highest retention wins. Ties broken by earliest index
* (stable). Returns `null` on empty input callers must guard.
* - `suggestedActionFor`: >= 0.92 'merge', < 0.85 'review'. The 0.85..0.92
* corridor follows the upstream `suggestedAction` field from the MCP tool,
* so we only override the obvious cases. Default for the corridor is
* whatever the caller already had this function returns null to signal
* "caller decides."
* - `filterByThreshold`: strict `>=` against the provided similarity.
* - `clusterKey`: stable identity across re-fetches sorted member ids
* joined. Survives threshold changes that keep the same cluster members.
*/
export type SimilarityBand = 'near-identical' | 'strong' | 'weak';
export type SuggestedAction = 'merge' | 'review';
export interface ClusterMemoryLike {
id: string;
retention: number;
tags?: string[];
createdAt?: string;
}
export interface ClusterLike<M extends ClusterMemoryLike = ClusterMemoryLike> {
similarity: number;
memories: M[];
}
/** Color bands. Boundary at 0.92 → red. Boundary at 0.80 → amber. */
export function similarityBand(similarity: number): SimilarityBand {
if (similarity >= 0.92) return 'near-identical';
if (similarity >= 0.8) return 'strong';
return 'weak';
}
export function similarityBandColor(similarity: number): string {
const band = similarityBand(similarity);
if (band === 'near-identical') return 'var(--color-decay)';
if (band === 'strong') return 'var(--color-warning)';
return '#fde047'; // yellow-300 — distinct from amber warning
}
export function similarityBandLabel(similarity: number): string {
const band = similarityBand(similarity);
if (band === 'near-identical') return 'Near-identical';
if (band === 'strong') return 'Strong match';
return 'Weak match';
}
/** Retention color dot. Matches the traffic-light scheme. */
export function retentionColor(retention: number): string {
if (retention > 0.7) return '#10b981';
if (retention > 0.4) return '#f59e0b';
return '#ef4444';
}
/**
* Pick the highest-retention memory. Stable tie-break: earliest wins.
* Returns `null` if the cluster is empty. Treats non-finite retention as
* -Infinity so a `retention=NaN` row never claims the throne.
*/
export function pickWinner<M extends ClusterMemoryLike>(memories: M[]): M | null {
if (!memories || memories.length === 0) return null;
let best = memories[0];
let bestScore = Number.isFinite(best.retention) ? best.retention : -Infinity;
for (let i = 1; i < memories.length; i++) {
const m = memories[i];
const s = Number.isFinite(m.retention) ? m.retention : -Infinity;
if (s > bestScore) {
best = m;
bestScore = s;
}
}
return best;
}
/**
* Suggested action inference. Returns null in the ambiguous 0.85..0.92 band
* so callers can honor an upstream suggestion from the backend.
*/
export function suggestedActionFor(similarity: number): SuggestedAction | null {
if (similarity >= 0.92) return 'merge';
if (similarity < 0.85) return 'review';
return null;
}
/**
* Filter clusters by the >= threshold contract. Separate pure function so the
* mock fetch and any future real fetch both get the same semantics.
*/
export function filterByThreshold<C extends ClusterLike>(clusters: C[], threshold: number): C[] {
return clusters.filter((c) => c.similarity >= threshold);
}
/**
* Stable identity across re-fetches. Uses sorted member ids, so a cluster
* that loses/gains a member gets a new key (intentional the cluster has
* changed). If you dismissed cluster [A,B,C] at 0.80 and refetch at 0.70
* and it now contains [A,B,C,D], it reappears correct behaviour: a new
* member deserves fresh attention.
*/
export function clusterKey<M extends ClusterMemoryLike>(memories: M[]): string {
return memories
.map((m) => m.id)
.slice()
.sort()
.join('|');
}
/**
* Safe content preview trims, collapses whitespace, truncates at 80 chars
* with an ellipsis. Null-safe.
*/
export function previewContent(content: string | null | undefined, max: number = 80): string {
if (!content) return '';
const trimmed = content.trim().replace(/\s+/g, ' ');
return trimmed.length <= max ? trimmed : trimmed.slice(0, max) + '…';
}
/**
* Render an ISO date string safely returns an empty string for missing,
* non-string, or invalid input so the DOM shows nothing rather than
* "Invalid Date".
*/
export function formatDate(iso: string | null | undefined): string {
if (!iso || typeof iso !== 'string') return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
/** Safe tag slice — tolerates undefined or non-array inputs. */
export function safeTags(tags: string[] | null | undefined, limit: number = 4): string[] {
if (!Array.isArray(tags)) return [];
return tags.slice(0, limit);
}

View file

@ -0,0 +1,226 @@
/**
* importance-helpers Pure logic for the Importance Radar UI
* (importance/+page.svelte + ImportanceRadar.svelte).
*
* Extracted so the radar geometry and importance-proxy maths can be unit-
* tested in the vitest `node` environment without jsdom or Svelte harness.
*
* Contracts
* ---------
* - Backend channel weights (novelty 0.25, arousal 0.30, reward 0.25,
* attention 0.20) sum to 1.0 and mirror ImportanceSignals in vestige-core.
* - `clamp01` folds NaN/Infinity/nullish 0 and clips [0,1].
* - `radarVertices` emits 4 SVG polygon points in the fixed axis order
* Novelty (top) Arousal (right) Reward (bottom) Attention (left).
* A zero value places the vertex at centre; a one value places it at the
* unit-ring edge.
* - `importanceProxy` is the SAME formula the page uses to rank the weekly
* list: retentionStrength × log1p(reviews + 1) / sqrt(max(1, ageDays)).
* Age is clamped to 1 so a freshly-created memory never divides by zero.
* - `sizePreset` maps 'sm'|'md'|'lg' to 80|180|320 and defaults to 'md' for
* any unknown size key matching the component's default prop.
*/
// -- Channel model ----------------------------------------------------------
export type ChannelKey = 'novelty' | 'arousal' | 'reward' | 'attention';
/** Weights applied server-side by ImportanceSignals. Must sum to 1.0. */
export const CHANNEL_WEIGHTS: Readonly<Record<ChannelKey, number>> = {
novelty: 0.25,
arousal: 0.3,
reward: 0.25,
attention: 0.2,
} as const;
export interface Channels {
novelty: number;
arousal: number;
reward: number;
attention: number;
}
/** Clamp a value to [0,1]. Null / undefined / NaN / Infinity → 0. */
export function clamp01(v: number | null | undefined): number {
if (v === null || v === undefined) return 0;
if (!Number.isFinite(v)) return 0;
if (v < 0) return 0;
if (v > 1) return 1;
return v;
}
/** Clamp every channel to [0,1]. Safe for partial / malformed inputs. */
export function clampChannels(ch: Partial<Channels> | null | undefined): Channels {
return {
novelty: clamp01(ch?.novelty),
arousal: clamp01(ch?.arousal),
reward: clamp01(ch?.reward),
attention: clamp01(ch?.attention),
};
}
/**
* Composite importance score matches backend ImportanceSignals.
*
* composite = 0.25·novelty + 0.30·arousal + 0.25·reward + 0.20·attention
*
* Every input is clamped first so out-of-range channels never puncture the
* 0..1 composite range. The return value is guaranteed to be in [0,1].
*/
export function compositeScore(ch: Partial<Channels> | null | undefined): number {
const c = clampChannels(ch);
return (
c.novelty * CHANNEL_WEIGHTS.novelty +
c.arousal * CHANNEL_WEIGHTS.arousal +
c.reward * CHANNEL_WEIGHTS.reward +
c.attention * CHANNEL_WEIGHTS.attention
);
}
// -- Size preset ------------------------------------------------------------
export type RadarSize = 'sm' | 'md' | 'lg';
export const SIZE_PX: Readonly<Record<RadarSize, number>> = {
sm: 80,
md: 180,
lg: 320,
} as const;
/**
* Resolve a size preset key to its px value. Unknown / missing keys fall
* back to 'md' (180), matching the component's default prop. `sm` loses
* axis labels in the renderer but that's rendering concern, not ours.
*/
export function sizePreset(size: RadarSize | string | undefined): number {
if (size && (size === 'sm' || size === 'md' || size === 'lg')) {
return SIZE_PX[size];
}
return SIZE_PX.md;
}
// -- Geometry ---------------------------------------------------------------
/**
* Fixed axis order. Angles use SVG conventions (y grows downward):
* Novelty angle -π/2 (top)
* Arousal angle 0 (right)
* Reward angle π/2 (bottom)
* Attention angle π (left)
*/
export const AXIS_ORDER: ReadonlyArray<{ key: ChannelKey; angle: number }> = [
{ key: 'novelty', angle: -Math.PI / 2 },
{ key: 'arousal', angle: 0 },
{ key: 'reward', angle: Math.PI / 2 },
{ key: 'attention', angle: Math.PI },
] as const;
export interface RadarPoint {
x: number;
y: number;
}
/**
* Compute the effective drawable radius inside the SVG box. This mirrors the
* component's padding logic:
* sm padding 4 (edge-to-edge, no labels)
* md padding 28
* lg padding 44
* Radius = size/2 padding, floored at 0 (a radius below zero would draw
* an inverted polygon defensive guard).
*/
export function radarRadius(size: RadarSize | string | undefined): number {
const px = sizePreset(size);
let padding: number;
switch (size) {
case 'lg':
padding = 44;
break;
case 'sm':
padding = 4;
break;
default:
padding = 28;
}
return Math.max(0, px / 2 - padding);
}
/**
* Compute the 4 SVG polygon vertices for a set of channel values at a given
* radar size. Values are clamped to [0,1] first so out-of-range inputs can't
* escape the radar bounds.
*
* Ordering is FIXED and matches AXIS_ORDER: [novelty, arousal, reward, attention].
* A zero value places the vertex at the centre (cx, cy); a one value places
* it at the unit-ring edge.
*/
export function radarVertices(
ch: Partial<Channels> | null | undefined,
size: RadarSize | string | undefined = 'md',
): RadarPoint[] {
const px = sizePreset(size);
const r = radarRadius(size);
const cx = px / 2;
const cy = px / 2;
const values = clampChannels(ch);
return AXIS_ORDER.map(({ key, angle }) => {
const v = values[key];
return {
x: cx + Math.cos(angle) * v * r,
y: cy + Math.sin(angle) * v * r,
};
});
}
/** Serialise vertices to an SVG "M…L…L…L… Z" path, 2-decimal precision. */
export function verticesToPath(points: RadarPoint[]): string {
if (points.length === 0) return '';
return (
points
.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(2)},${p.y.toFixed(2)}`)
.join(' ') + ' Z'
);
}
// -- Trending-memory proxy --------------------------------------------------
export interface ProxyMemoryLike {
retentionStrength: number;
reviewCount?: number | null;
createdAt: string;
}
/**
* Proxy score for the "Top Important Memories This Week" ranking. Exact
* formula from importance/+page.svelte:
*
* ageDays = max(1, (now - createdAt) / 86_400_000)
* reviews = reviewCount ?? 0
* recencyBoost = 1 / sqrt(ageDays)
* proxy = retentionStrength × log1p(reviews + 1) × recencyBoost
*
* Edge cases:
* - createdAt is the current instant ageDays clamps to 1 (no div-by-0)
* - createdAt is in the future negative age also clamps to 1
* - reviewCount null/undefined treated as 0
* - non-finite retentionStrength returns 0 defensively
*
* `now` is injectable for deterministic tests. Defaults to `Date.now()`.
*/
export function importanceProxy(m: ProxyMemoryLike, now: number = Date.now()): number {
if (!m || !Number.isFinite(m.retentionStrength)) return 0;
const created = new Date(m.createdAt).getTime();
if (!Number.isFinite(created)) return 0;
const ageDays = Math.max(1, (now - created) / 86_400_000);
const reviews = m.reviewCount ?? 0;
const recencyBoost = 1 / Math.sqrt(ageDays);
return m.retentionStrength * Math.log1p(reviews + 1) * recencyBoost;
}
/** Sort memories by the proxy, descending. Stable via `.sort` on a copy. */
export function rankByProxy<M extends ProxyMemoryLike>(
memories: readonly M[],
now: number = Date.now(),
): M[] {
return memories.slice().sort((a, b) => importanceProxy(b, now) - importanceProxy(a, now));
}

View file

@ -0,0 +1,178 @@
/**
* patterns-helpers Pure logic for the Cross-Project Intelligence UI
* (patterns/+page.svelte + PatternTransferHeatmap.svelte).
*
* Extracted so the behaviour can be unit-tested in the vitest `node`
* environment without jsdom or Svelte component harnessing. Every helper
* in this module is a pure function of its inputs.
*
* Contracts
* ---------
* - `cellIntensity`: returns opacity in [0,1] from count / max. count=0 0,
* count>=max 1. `max<=0` collapses to 0 (avoids div-by-zero the
* component uses `max || 1` for the same reason).
* - `filterByCategory`: 'All' passes every pattern through. An unknown
* category string (not one of the 6 + 'All') returns an empty array
* there is no hidden alias fallback.
* - `buildTransferMatrix`: directional. `matrix[origin][dest]` counts how
* many patterns originated in `origin` and were transferred to `dest`.
* `origin === dest` captures self-transfer (a project reusing its own
* pattern rare but real per the component's doc comment).
*/
export const PATTERN_CATEGORIES = [
'ErrorHandling',
'AsyncConcurrency',
'Testing',
'Architecture',
'Performance',
'Security',
] as const;
export type PatternCategory = (typeof PATTERN_CATEGORIES)[number];
export type CategoryFilter = 'All' | PatternCategory;
export interface TransferPatternLike {
name: string;
category: PatternCategory;
origin_project: string;
transferred_to: string[];
transfer_count: number;
}
/**
* Normalise a raw transfer count to a 0..1 opacity/intensity value against a
* known max. Used by the heatmap cell colour ramp.
*
* count <= 0 0 (dead cell)
* count >= max > 0 1 (hottest cell)
* otherwise count / max
*
* Non-finite / negative inputs collapse to 0. When `max <= 0` the result is
* always 0 the component's own guard (`maxCount || 1`) means this branch
* is unreachable in practice, but defensive anyway.
*/
export function cellIntensity(count: number, max: number): number {
if (!Number.isFinite(count) || count <= 0) return 0;
if (!Number.isFinite(max) || max <= 0) return 0;
if (count >= max) return 1;
return count / max;
}
/**
* Filter a pattern list by the active category tab.
* 'All' full pass-through (same reference-equal array is
* NOT guaranteed; callers must not rely on identity)
* one of the 6 enums strict equality on `category`
* unknown string empty array (no silent alias; caller bug)
*/
export function filterByCategory<P extends TransferPatternLike>(
patterns: readonly P[],
category: CategoryFilter | string,
): P[] {
if (category === 'All') return patterns.slice();
if (!(PATTERN_CATEGORIES as readonly string[]).includes(category)) {
return [];
}
return patterns.filter((p) => p.category === category);
}
/** Cell in the directional N×N transfer matrix. */
export interface TransferCell {
count: number;
topNames: string[];
}
/** Dense row-major directional matrix: matrix[origin][destination]. */
export type TransferMatrix = Record<string, Record<string, TransferCell>>;
/**
* Build the directional transfer matrix from patterns + the known projects
* axis. Mirrors `PatternTransferHeatmap.svelte`'s `$derived` logic.
*
* - Every (from, to) pair in `projects × projects` gets a zero cell.
* - Each pattern P contributes `+1` to `matrix[P.origin][dest]` for every
* `dest` in `P.transferred_to` that also appears in `projects`.
* - Patterns whose origin isn't in `projects` are silently skipped that
* matches the component's `if (!m[from]) continue` guard.
* - `topNames` holds up to 3 pattern names per cell in insertion order.
*/
export function buildTransferMatrix(
projects: readonly string[],
patterns: readonly TransferPatternLike[],
topNameCap = 3,
): TransferMatrix {
const m: TransferMatrix = {};
for (const from of projects) {
m[from] = {};
for (const to of projects) {
m[from][to] = { count: 0, topNames: [] };
}
}
for (const p of patterns) {
const from = p.origin_project;
if (!m[from]) continue;
for (const to of p.transferred_to) {
if (!m[from][to]) continue;
m[from][to].count += 1;
m[from][to].topNames.push(p.name);
}
}
const cap = Math.max(0, topNameCap);
for (const from of projects) {
for (const to of projects) {
m[from][to].topNames = m[from][to].topNames.slice(0, cap);
}
}
return m;
}
/**
* Maximum single-cell transfer count across the matrix. Floors at 0 for an
* empty matrix, which callers should treat as "scale by 1" to avoid a div-
* by-zero in the colour ramp.
*/
export function matrixMaxCount(
projects: readonly string[],
matrix: TransferMatrix,
): number {
let max = 0;
for (const from of projects) {
const row = matrix[from];
if (!row) continue;
for (const to of projects) {
const cell = row[to];
if (cell && cell.count > max) max = cell.count;
}
}
return max;
}
/**
* Flatten a matrix into sorted-desc rows for the mobile fallback. Only
* non-zero pairs are emitted, matching the component.
*/
export function flattenNonZero(
projects: readonly string[],
matrix: TransferMatrix,
): Array<{ from: string; to: string; count: number; topNames: string[] }> {
const rows: Array<{ from: string; to: string; count: number; topNames: string[] }> = [];
for (const from of projects) {
for (const to of projects) {
const cell = matrix[from]?.[to];
if (cell && cell.count > 0) {
rows.push({ from, to, count: cell.count, topNames: cell.topNames });
}
}
}
return rows.sort((a, b) => b.count - a.count);
}
/**
* Truncate long project names for axis labels. Match the component's
* `shortProject` behaviour: keep 12 chars, otherwise 11-char prefix + ellipsis.
*/
export function shortProjectName(name: string): string {
if (!name) return '';
return name.length > 12 ? name.slice(0, 11) + '…' : name;
}

View file

@ -0,0 +1,229 @@
/**
* reasoning-helpers Pure logic for the Reasoning Theater UI.
*
* Extracted so we can test it without jsdom / Svelte component harnessing.
* The Vitest setup for this package runs in a Node environment; every helper
* in this module is a pure function of its inputs, so it can be exercised
* directly in `__tests__/*.test.ts` alongside the graph helpers.
*/
import { NODE_TYPE_COLORS } from '$types';
// ────────────────────────────────────────────────────────────────
// Shared palette — keep in sync with Tailwind @theme values.
// ────────────────────────────────────────────────────────────────
export const CONFIDENCE_EMERALD = '#10b981';
export const CONFIDENCE_AMBER = '#f59e0b';
export const CONFIDENCE_RED = '#ef4444';
/** Fallback colour when a node-type has no mapping. */
export const DEFAULT_NODE_TYPE_COLOR = '#8B95A5';
// ────────────────────────────────────────────────────────────────
// Roles
// ────────────────────────────────────────────────────────────────
export type EvidenceRole = 'primary' | 'supporting' | 'contradicting' | 'superseded';
export interface RoleMeta {
label: string;
/** Tailwind / CSS colour token — see app.css. */
accent: 'synapse' | 'recall' | 'decay' | 'muted';
icon: string;
}
export const ROLE_META: Record<EvidenceRole, RoleMeta> = {
primary: { label: 'Primary', accent: 'synapse', icon: '◈' },
supporting: { label: 'Supporting', accent: 'recall', icon: '◇' },
contradicting: { label: 'Contradicting', accent: 'decay', icon: '⚠' },
superseded: { label: 'Superseded', accent: 'muted', icon: '⊘' },
};
/** Look up role metadata with a defensive fallback. */
export function roleMetaFor(role: EvidenceRole | string): RoleMeta {
return (ROLE_META as Record<string, RoleMeta>)[role] ?? ROLE_META.supporting;
}
// ────────────────────────────────────────────────────────────────
// Intent classification (deep_reference `intent` field)
// ────────────────────────────────────────────────────────────────
export type IntentKey =
| 'FactCheck'
| 'Timeline'
| 'RootCause'
| 'Comparison'
| 'Synthesis';
export interface IntentHint {
label: string;
icon: string;
description: string;
}
export const INTENT_HINTS: Record<IntentKey, IntentHint> = {
FactCheck: {
label: 'FactCheck',
icon: '◆',
description: 'Direct verification of a single claim.',
},
Timeline: {
label: 'Timeline',
icon: '↗',
description: 'Ordered evolution of a fact over time.',
},
RootCause: {
label: 'RootCause',
icon: '⚡',
description: 'Why did this happen — causal chain.',
},
Comparison: {
label: 'Comparison',
icon: '⬡',
description: 'Contrasting two or more options side-by-side.',
},
Synthesis: {
label: 'Synthesis',
icon: '❖',
description: 'Cross-memory composition into a new insight.',
},
};
/**
* Map an arbitrary intent string to a hint. Unknown intents degrade to
* Synthesis, which is the most generic classification.
*/
export function intentHintFor(intent: string | undefined | null): IntentHint {
if (!intent) return INTENT_HINTS.Synthesis;
const key = intent as IntentKey;
return INTENT_HINTS[key] ?? INTENT_HINTS.Synthesis;
}
// ────────────────────────────────────────────────────────────────
// Confidence bands
// ────────────────────────────────────────────────────────────────
/**
* Confidence colour band.
*
* > 75 emerald (HIGH)
* 40-75 amber (MIXED)
* < 40 red (LOW)
*
* Boundaries: 75 is amber (strictly greater than 75 is emerald), 40 is amber
* (>=40 is amber). Any non-finite input (NaN) is treated as lowest confidence
* and returns red.
*/
export function confidenceColor(c: number): string {
if (!Number.isFinite(c)) return CONFIDENCE_RED;
if (c > 75) return CONFIDENCE_EMERALD;
if (c >= 40) return CONFIDENCE_AMBER;
return CONFIDENCE_RED;
}
/** Human-readable label for a confidence score (0-100). */
export function confidenceLabel(c: number): string {
if (!Number.isFinite(c)) return 'LOW CONFIDENCE';
if (c > 75) return 'HIGH CONFIDENCE';
if (c >= 40) return 'MIXED SIGNAL';
return 'LOW CONFIDENCE';
}
/**
* Convert a 0-1 trust score to the same confidence band.
*
* Thresholds: >0.75 emerald, 0.40-0.75 amber, <0.40 red.
* Matches `confidenceColor` semantics so the trust bar on an evidence card
* and the confidence meter on the page agree at the boundaries.
*/
export function trustColor(t: number): string {
if (!Number.isFinite(t)) return CONFIDENCE_RED;
return confidenceColor(t * 100);
}
/** Clamp a trust score into the display range [0, 1]. */
export function clampTrust(t: number): number {
if (!Number.isFinite(t)) return 0;
if (t < 0) return 0;
if (t > 1) return 1;
return t;
}
/** Trust as a 0-100 percentage suitable for width / label rendering. */
export function trustPercent(t: number): number {
return clampTrust(t) * 100;
}
// ────────────────────────────────────────────────────────────────
// Node-type colouring
// ────────────────────────────────────────────────────────────────
/** Resolve a node-type colour with a soft-steel fallback. */
export function nodeTypeColor(nodeType?: string | null): string {
if (!nodeType) return DEFAULT_NODE_TYPE_COLOR;
return NODE_TYPE_COLORS[nodeType] ?? DEFAULT_NODE_TYPE_COLOR;
}
// ────────────────────────────────────────────────────────────────
// Date formatting
// ────────────────────────────────────────────────────────────────
/**
* Format an ISO date string for EvidenceCard display.
*
* Handles three failure modes that `new Date(str)` alone does not:
* 1. Empty / null / undefined returns '—'
* 2. Unparseable string (NaN) returns the original string unchanged
* 3. Non-ISO but parseable best-effort locale format
*
* The previous try/catch-only approach silently rendered the literal text
* "Invalid Date" because `Date` never throws on bad input it produces a
* valid object whose getTime() is NaN.
*/
export function formatDate(
iso: string | null | undefined,
locale?: string,
): string {
if (iso == null) return '—';
if (typeof iso !== 'string' || iso.trim() === '') return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
try {
return d.toLocaleDateString(locale, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
} catch {
return iso;
}
}
/** Compact month/day formatter for the evolution timeline. */
export function formatShortDate(
iso: string | null | undefined,
locale?: string,
): string {
if (iso == null) return '—';
if (typeof iso !== 'string' || iso.trim() === '') return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
try {
return d.toLocaleDateString(locale, { month: 'short', day: 'numeric' });
} catch {
return iso;
}
}
// ────────────────────────────────────────────────────────────────
// Short-id for #abcdef01 style display
// ────────────────────────────────────────────────────────────────
/**
* Return the first 8 characters of an id, or the full string if shorter.
* Never throws on null/undefined returns '' so the caller can render '#'.
*/
export function shortenId(id: string | null | undefined, length = 8): string {
if (!id) return '';
return id.length > length ? id.slice(0, length) : id;
}

View file

@ -0,0 +1,161 @@
/**
* Pure helpers for the FSRS review schedule page + calendar.
*
* Extracted from `FSRSCalendar.svelte` and `routes/(app)/schedule/+page.svelte`
* so that bucket / grid / urgency / retention math can be tested in isolation
* (vitest `environment: node`, no jsdom required).
*/
import type { Memory } from '$types';
export const MS_DAY = 24 * 60 * 60 * 1000;
/**
* Zero-out the time component of a date, returning a NEW Date at local
* midnight. Used for day-granular bucketing so comparisons are stable across
* any hour-of-day the user loads the page.
*/
export function startOfDay(d: Date | string): Date {
const x = typeof d === 'string' ? new Date(d) : new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
/**
* Signed integer count of whole local days between two timestamps, normalized
* to midnight. Positive means `a` is in the future relative to `b`, negative
* means `a` is in the past. Zero means same calendar day.
*/
export function daysBetween(a: Date, b: Date): number {
return Math.floor((startOfDay(a).getTime() - startOfDay(b).getTime()) / MS_DAY);
}
/** YYYY-MM-DD in LOCAL time (not UTC) so calendar cells align with user's day. */
export function isoDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
/**
* Urgency bucket for a review date relative to "now". Used by the right-hand
* list and the calendar cell color. Day-granular (not hour-granular) so a
* memory due at 23:59 today does not suddenly become "in 1d" at 00:01
* tomorrow UX-wise it becomes "overdue" cleanly at midnight.
*
* - `none` no valid `nextReviewAt`
* - `overdue` due date's calendar day is strictly before today
* - `today` due date's calendar day is today
* - `week` due in 1..=7 whole days
* - `future` due in 8+ whole days
*/
export type Urgency = 'none' | 'overdue' | 'today' | 'week' | 'future';
export function classifyUrgency(now: Date, nextReviewAt: string | null | undefined): Urgency {
if (!nextReviewAt) return 'none';
const d = new Date(nextReviewAt);
if (Number.isNaN(d.getTime())) return 'none';
const delta = daysBetween(d, now);
if (delta < 0) return 'overdue';
if (delta === 0) return 'today';
if (delta <= 7) return 'week';
return 'future';
}
/**
* Signed whole-day count from today due date. Negative means overdue by
* |n| days; zero means today; positive means n days out. Returns `null`
* if the ISO string is invalid or missing.
*/
export function daysUntilReview(now: Date, nextReviewAt: string | null | undefined): number | null {
if (!nextReviewAt) return null;
const d = new Date(nextReviewAt);
if (Number.isNaN(d.getTime())) return null;
return daysBetween(d, now);
}
/**
* The [start, end) window for the week containing `d`, starting Sunday at
* local midnight. End is the following Sunday at local midnight exclusive.
*/
export function weekBucketRange(d: Date): { start: Date; end: Date } {
const start = startOfDay(d);
start.setDate(start.getDate() - start.getDay()); // back to Sunday
const end = new Date(start);
end.setDate(end.getDate() + 7);
return { start, end };
}
/**
* Mean retention strength across a list of memories. Returns 0 for an empty
* list (never NaN) so the sidebar can safely render "0%".
*/
export function avgRetention(memories: Memory[]): number {
if (memories.length === 0) return 0;
let sum = 0;
for (const m of memories) sum += m.retentionStrength ?? 0;
return sum / memories.length;
}
/**
* Given a day-index `i` into a 42-cell calendar grid (6 rows × 7 cols), return
* its row / column. The grid is laid out row-major: cell 0 = row 0 col 0,
* cell 7 = row 1 col 0, cell 41 = row 5 col 6. Returns `null` for indices
* outside `[0, 42)`.
*/
export function gridCellPosition(i: number): { row: number; col: number } | null {
if (!Number.isInteger(i) || i < 0 || i >= 42) return null;
return { row: Math.floor(i / 7), col: i % 7 };
}
/**
* The inverse: given a calendar anchor date (today), compute the Sunday
* at-or-before `anchor - 14 days` that seeds row 0 of the 6×7 grid. Pure,
* deterministic, local-time.
*/
export function gridStartForAnchor(anchor: Date): Date {
const base = startOfDay(anchor);
base.setDate(base.getDate() - 14);
base.setDate(base.getDate() - base.getDay()); // back to Sunday
return base;
}
/**
* Bucket counts used by the sidebar stats block. Day-granular, consistent
* with `classifyUrgency`.
*/
export interface ScheduleStats {
overdue: number;
dueToday: number;
dueThisWeek: number;
dueThisMonth: number;
avgDays: number;
}
export function computeScheduleStats(now: Date, scheduled: Memory[]): ScheduleStats {
let overdue = 0;
let dueToday = 0;
let dueThisWeek = 0;
let dueThisMonth = 0;
let sumDays = 0;
let futureCount = 0;
const today = startOfDay(now);
for (const m of scheduled) {
if (!m.nextReviewAt) continue;
const d = new Date(m.nextReviewAt);
if (Number.isNaN(d.getTime())) continue;
const delta = daysBetween(d, now);
if (delta < 0) overdue++;
if (delta <= 0) dueToday++;
if (delta <= 7) dueThisWeek++;
if (delta <= 30) dueThisMonth++;
if (delta >= 0) {
// Use hour-resolution days-until for the average so "due in 2.3 days"
// is informative even when bucketing is day-granular elsewhere.
sumDays += (d.getTime() - today.getTime()) / MS_DAY;
futureCount++;
}
}
const avgDays = futureCount > 0 ? sumDays / futureCount : 0;
return { overdue, dueToday, dueThisWeek, dueThisMonth, avgDays };
}

View file

@ -0,0 +1,496 @@
/**
* Unit tests for the theme store.
*
* Scope: pure-store behavior setter validation, cycle order, derived
* resolution, localStorage persistence + fallback, matchMedia listener
* wiring, idempotent style injection, SSR safety.
*
* Environment notes:
* - Vitest runs in Node (no jsdom). We fabricate the window / document /
* localStorage / matchMedia globals the store touches, then mock
* `$app/environment` so `browser` flips between true and false per
* test group. SSR tests leave `browser` false and verify no globals
* are touched.
* - The store caches module-level state (mediaQuery, listener,
* resolvedUnsub). We `vi.resetModules()` before every test so each
* loadTheme() returns a pristine instance.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { get } from 'svelte/store';
// --- Controllable `browser` flag ------------------------------------------
// vi.mock is hoisted — we reference a module-level `browserFlag` the tests
// mutate between blocks. Casting via globalThis keeps the hoist happy.
const browserState = { value: true };
vi.mock('$app/environment', () => ({
get browser() {
return browserState.value;
},
}));
// --- Fabricated DOM / storage / matchMedia --------------------------------
// Each test's setup wires these onto globalThis so the store's `browser`
// branch can read them. They are intentionally minimal — only the methods
// theme.ts actually calls are implemented.
type FakeMediaListener = (e: { matches: boolean }) => void;
interface FakeMediaQueryList {
matches: boolean;
addEventListener: (type: 'change', listener: FakeMediaListener) => void;
removeEventListener: (type: 'change', listener: FakeMediaListener) => void;
// Test-only helpers
_emit: (matches: boolean) => void;
_listenerCount: () => number;
}
function createFakeMediaQuery(initialMatches: boolean): FakeMediaQueryList {
const listeners = new Set<FakeMediaListener>();
return {
matches: initialMatches,
addEventListener: (_type, listener) => {
listeners.add(listener);
},
removeEventListener: (_type, listener) => {
listeners.delete(listener);
},
_emit(matches: boolean) {
this.matches = matches;
for (const l of listeners) l({ matches });
},
_listenerCount() {
return listeners.size;
},
};
}
interface FakeStorageBehavior {
throwOnGet?: boolean;
throwOnSet?: boolean;
corruptRaw?: string | null;
}
function installFakeLocalStorage(behavior: FakeStorageBehavior = {}) {
const store = new Map<string, string>();
if (behavior.corruptRaw !== undefined && behavior.corruptRaw !== null) {
store.set('vestige.theme', behavior.corruptRaw);
}
const fake = {
getItem: (key: string) => {
if (behavior.throwOnGet) throw new Error('SecurityError: storage disabled');
return store.has(key) ? store.get(key)! : null;
},
setItem: (key: string, value: string) => {
if (behavior.throwOnSet) throw new Error('QuotaExceededError');
store.set(key, value);
},
removeItem: (key: string) => {
store.delete(key);
},
clear: () => store.clear(),
key: () => null,
length: 0,
_store: store, // test-only peek
};
vi.stubGlobal('localStorage', fake);
return fake;
}
/**
* Install a fake `document` with only the APIs theme.ts calls:
* - getElementById (style-dedup check)
* - createElement('style')
* - head.appendChild
* - documentElement.dataset
* Returns handles so tests can inspect the head children and data-theme.
*/
function installFakeDocument() {
const headChildren: Array<{ id: string; textContent: string; tagName: string }> = [];
const docEl = {
dataset: {} as Record<string, string>,
};
const fakeDocument = {
getElementById: (id: string) =>
headChildren.find((el) => el.id === id) ?? null,
createElement: (tag: string) => ({
id: '',
textContent: '',
tagName: tag.toUpperCase(),
}),
head: {
appendChild: (el: { id: string; textContent: string; tagName: string }) => {
headChildren.push(el);
return el;
},
},
documentElement: docEl,
};
vi.stubGlobal('document', fakeDocument);
return { fakeDocument, headChildren, docEl };
}
/**
* Install a fake `window` with just `matchMedia`. We keep the returned
* MQL handle so tests can emit change events.
*/
function installFakeWindow(initialPrefersDark: boolean) {
const mql = createFakeMediaQuery(initialPrefersDark);
const fakeWindow = {
matchMedia: vi.fn(() => mql),
};
vi.stubGlobal('window', fakeWindow);
return { fakeWindow, mql };
}
/**
* Fresh module import. The theme store caches matchMedia/listener handles
* at module level, so every test that exercises initTheme wants a clean
* copy. Returns the full export surface.
*/
async function loadTheme() {
vi.resetModules();
return await import('../theme');
}
// Baseline: every test starts with browser=true, fake window/doc/storage
// installed, and fresh module state. SSR-specific tests override these.
beforeEach(() => {
browserState.value = true;
installFakeDocument();
installFakeWindow(true); // system prefers dark by default
installFakeLocalStorage();
});
afterEach(() => {
vi.unstubAllGlobals();
});
// ---------------------------------------------------------------------------
// Export surface
// ---------------------------------------------------------------------------
describe('theme store — exports', () => {
it('exports theme writable, resolvedTheme derived, setTheme, cycleTheme, initTheme', async () => {
const mod = await loadTheme();
expect(mod.theme).toBeDefined();
expect(typeof mod.theme.subscribe).toBe('function');
expect(typeof mod.theme.set).toBe('function');
expect(mod.resolvedTheme).toBeDefined();
expect(typeof mod.resolvedTheme.subscribe).toBe('function');
// Derived stores do NOT expose .set — this guards against accidental
// conversion to a writable during refactors.
expect((mod.resolvedTheme as unknown as { set?: unknown }).set).toBeUndefined();
expect(typeof mod.setTheme).toBe('function');
expect(typeof mod.cycleTheme).toBe('function');
expect(typeof mod.initTheme).toBe('function');
});
it('theme defaults to dark before initTheme is called', async () => {
const mod = await loadTheme();
expect(get(mod.theme)).toBe('dark');
});
});
// ---------------------------------------------------------------------------
// setTheme — input validation + persistence
// ---------------------------------------------------------------------------
describe('setTheme', () => {
it('accepts dark/light/auto and updates the store', async () => {
const { theme, setTheme } = await loadTheme();
setTheme('light');
expect(get(theme)).toBe('light');
setTheme('auto');
expect(get(theme)).toBe('auto');
setTheme('dark');
expect(get(theme)).toBe('dark');
});
it('rejects invalid values — store is unchanged, localStorage untouched', async () => {
const { theme, setTheme } = await loadTheme();
setTheme('light'); // seed a known value
const ls = installFakeLocalStorage();
// Reset any prior writes so we only see what happens during the bad call.
ls._store.clear();
// Cast a bad value through the public API.
setTheme('midnight' as unknown as 'dark');
expect(get(theme)).toBe('light'); // unchanged
expect(ls._store.has('vestige.theme')).toBe(false);
setTheme('' as unknown as 'dark');
setTheme(undefined as unknown as 'dark');
setTheme(null as unknown as 'dark');
expect(get(theme)).toBe('light');
});
it('persists the valid value to localStorage under the vestige.theme key', async () => {
const ls = installFakeLocalStorage();
const { setTheme } = await loadTheme();
setTheme('auto');
expect(ls._store.get('vestige.theme')).toBe('auto');
});
it('swallows localStorage write errors (private mode / disabled storage)', async () => {
installFakeLocalStorage({ throwOnSet: true });
const { theme, setTheme } = await loadTheme();
// Must not throw.
expect(() => setTheme('light')).not.toThrow();
// Store still updated even though persistence failed — UI should
// reflect the click; the next session will just start fresh.
expect(get(theme)).toBe('light');
});
it('no-ops localStorage write when browser=false (SSR safety)', async () => {
browserState.value = false;
const ls = installFakeLocalStorage();
const { theme, setTheme } = await loadTheme();
setTheme('light');
// Store update is still safe (pure JS object), but persistence is skipped.
expect(get(theme)).toBe('light');
expect(ls._store.has('vestige.theme')).toBe(false);
});
});
// ---------------------------------------------------------------------------
// cycleTheme — dark → light → auto → dark
// ---------------------------------------------------------------------------
describe('cycleTheme', () => {
it('cycles dark → light', async () => {
const { theme, cycleTheme } = await loadTheme();
// Default is 'dark'.
expect(get(theme)).toBe('dark');
cycleTheme();
expect(get(theme)).toBe('light');
});
it('cycles light → auto', async () => {
const { theme, setTheme, cycleTheme } = await loadTheme();
setTheme('light');
cycleTheme();
expect(get(theme)).toBe('auto');
});
it('cycles auto → dark (closes the loop)', async () => {
const { theme, setTheme, cycleTheme } = await loadTheme();
setTheme('auto');
cycleTheme();
expect(get(theme)).toBe('dark');
});
it('full triple-click returns to the starting value', async () => {
const { theme, cycleTheme } = await loadTheme();
const start = get(theme);
cycleTheme();
cycleTheme();
cycleTheme();
expect(get(theme)).toBe(start);
});
it('persists each step to localStorage', async () => {
const ls = installFakeLocalStorage();
const { cycleTheme } = await loadTheme();
cycleTheme();
expect(ls._store.get('vestige.theme')).toBe('light');
cycleTheme();
expect(ls._store.get('vestige.theme')).toBe('auto');
cycleTheme();
expect(ls._store.get('vestige.theme')).toBe('dark');
});
});
// ---------------------------------------------------------------------------
// resolvedTheme — derived from theme + systemPrefersDark
// ---------------------------------------------------------------------------
describe('resolvedTheme', () => {
it('dark → dark (independent of system preference)', async () => {
const { resolvedTheme, setTheme } = await loadTheme();
setTheme('dark');
expect(get(resolvedTheme)).toBe('dark');
});
it('light → light (independent of system preference)', async () => {
const { resolvedTheme, setTheme } = await loadTheme();
setTheme('light');
expect(get(resolvedTheme)).toBe('light');
});
it('auto + system prefers dark → dark', async () => {
const { mql } = installFakeWindow(true);
const { resolvedTheme, setTheme, initTheme } = await loadTheme();
initTheme(); // primes systemPrefersDark from matchMedia
setTheme('auto');
expect(mql.matches).toBe(true);
expect(get(resolvedTheme)).toBe('dark');
});
it('auto + system prefers light → light', async () => {
installFakeWindow(false);
const { resolvedTheme, setTheme, initTheme } = await loadTheme();
initTheme(); // primes systemPrefersDark=false
setTheme('auto');
expect(get(resolvedTheme)).toBe('light');
});
it('auto flips live when the matchMedia listener fires (OS changes scheme)', async () => {
const { mql } = installFakeWindow(true);
const { resolvedTheme, setTheme, initTheme } = await loadTheme();
initTheme();
setTheme('auto');
expect(get(resolvedTheme)).toBe('dark');
// OS user toggles to light mode — matchMedia fires 'change' with matches=false.
mql._emit(false);
expect(get(resolvedTheme)).toBe('light');
// And back to dark.
mql._emit(true);
expect(get(resolvedTheme)).toBe('dark');
});
});
// ---------------------------------------------------------------------------
// initTheme — idempotence, teardown, localStorage hydration
// ---------------------------------------------------------------------------
describe('initTheme', () => {
it('returns a teardown function', async () => {
const { initTheme } = await loadTheme();
const teardown = initTheme();
expect(typeof teardown).toBe('function');
teardown();
});
it('injects exactly one <style id="vestige-theme-light"> into <head>', async () => {
const { headChildren } = installFakeDocument();
const { initTheme } = await loadTheme();
initTheme();
const styleEls = headChildren.filter((el) => el.id === 'vestige-theme-light');
expect(styleEls.length).toBe(1);
expect(styleEls[0].tagName).toBe('STYLE');
// Sanity — CSS uses the REAL token names from app.css.
expect(styleEls[0].textContent).toContain('--color-void');
expect(styleEls[0].textContent).toContain('--color-bright');
expect(styleEls[0].textContent).toContain('--color-text');
expect(styleEls[0].textContent).toContain("[data-theme='light']");
});
it('is idempotent — double init does NOT duplicate the style element', async () => {
const { headChildren } = installFakeDocument();
const { initTheme } = await loadTheme();
initTheme();
initTheme();
initTheme();
const styleEls = headChildren.filter((el) => el.id === 'vestige-theme-light');
expect(styleEls.length).toBe(1);
});
it('double init does not leak matchMedia listeners (tears down the prior one)', async () => {
const { mql } = installFakeWindow(true);
const { initTheme } = await loadTheme();
initTheme();
expect(mql._listenerCount()).toBe(1);
initTheme();
// Still exactly one — the second init removed the first before adding a new one.
expect(mql._listenerCount()).toBe(1);
initTheme();
expect(mql._listenerCount()).toBe(1);
});
it('teardown removes the matchMedia listener', async () => {
const { mql } = installFakeWindow(true);
const { initTheme } = await loadTheme();
const teardown = initTheme();
expect(mql._listenerCount()).toBe(1);
teardown();
expect(mql._listenerCount()).toBe(0);
});
it('hydrates theme from localStorage when a valid value is stored', async () => {
installFakeLocalStorage({ corruptRaw: 'light' });
const { theme, initTheme } = await loadTheme();
initTheme();
expect(get(theme)).toBe('light');
});
it('falls back to dark when localStorage contains a corrupt/unknown value', async () => {
installFakeLocalStorage({ corruptRaw: 'hyperdark' });
const { theme, initTheme } = await loadTheme();
initTheme();
expect(get(theme)).toBe('dark');
});
it('falls back to dark when localStorage.getItem throws (private mode)', async () => {
installFakeLocalStorage({ throwOnGet: true });
const { theme, initTheme } = await loadTheme();
// Must not throw — error swallowed, default preserved.
expect(() => initTheme()).not.toThrow();
expect(get(theme)).toBe('dark');
});
it('writes documentElement.dataset.theme to the resolved value', async () => {
const { docEl } = installFakeDocument();
installFakeWindow(true);
const { setTheme, initTheme } = await loadTheme();
initTheme();
setTheme('light');
expect(docEl.dataset.theme).toBe('light');
setTheme('dark');
expect(docEl.dataset.theme).toBe('dark');
// auto + system=dark → 'dark'
setTheme('auto');
expect(docEl.dataset.theme).toBe('dark');
});
it('uses the correct matchMedia query: (prefers-color-scheme: dark)', async () => {
const { fakeWindow } = installFakeWindow(true);
const { initTheme } = await loadTheme();
initTheme();
expect(fakeWindow.matchMedia).toHaveBeenCalledWith('(prefers-color-scheme: dark)');
});
});
// ---------------------------------------------------------------------------
// SSR safety — browser=false means every function is a safe no-op
// ---------------------------------------------------------------------------
describe('SSR safety (browser=false)', () => {
beforeEach(() => {
browserState.value = false;
// Deliberately DO NOT install fake window/document/localStorage.
// If the store touches them while browser=false, ReferenceError fires.
vi.unstubAllGlobals();
// But `setup.ts` (shared graph test setup) installs a minimal global
// `document` stub. That's fine — the point is the store must not
// call window.matchMedia or localStorage while browser=false.
});
it('initTheme returns a no-op teardown and does not throw', async () => {
const { initTheme } = await loadTheme();
let teardown: () => void = () => {};
expect(() => {
teardown = initTheme();
}).not.toThrow();
expect(typeof teardown).toBe('function');
expect(() => teardown()).not.toThrow();
});
it('setTheme updates the store but skips localStorage', async () => {
const { theme, setTheme } = await loadTheme();
expect(() => setTheme('light')).not.toThrow();
expect(get(theme)).toBe('light');
});
it('cycleTheme cycles without touching browser globals', async () => {
const { theme, cycleTheme } = await loadTheme();
expect(() => cycleTheme()).not.toThrow();
expect(get(theme)).toBe('light');
});
it('resolvedTheme returns the concrete value for dark/light, defaults to dark for auto', async () => {
const { resolvedTheme, setTheme } = await loadTheme();
setTheme('dark');
expect(get(resolvedTheme)).toBe('dark');
setTheme('light');
expect(get(resolvedTheme)).toBe('light');
// In SSR we never primed matchMedia, so systemPrefersDark is its
// default (true) → auto resolves to dark. This keeps server-rendered
// HTML matching the dark-first design.
setTheme('auto');
expect(get(resolvedTheme)).toBe('dark');
});
});

View file

@ -0,0 +1,254 @@
// Theme store — closes GitHub issue #11 (Dark/Light theme toggle).
//
// Design:
// - Three modes: 'dark' (current default, bioluminescent), 'light'
// (slate-on-white, muted glow), 'auto' (follows system preference).
// - A single writable `theme` store holds the user preference.
// A derived `resolvedTheme` collapses 'auto' into the concrete 'dark' |
// 'light' that matchMedia is reporting right now.
// - On any change we flip `document.documentElement.dataset.theme`. All
// CSS variable overrides key off `[data-theme='light']` in the
// injected stylesheet below (app.css is deliberately left untouched so
// the dark defaults still cascade when no attribute is set).
// - Preference persists to `localStorage['vestige.theme']`.
// - `initTheme()` is called once from +layout.svelte onMount. It (a)
// reads localStorage, (b) injects the light-mode stylesheet into
// <head>, (c) sets dataset.theme, (d) attaches a matchMedia listener
// so 'auto' tracks the OS in real time.
//
// Light-mode override strategy:
// We inject a single <style id="vestige-theme-light"> block at init time
// rather than editing app.css. This keeps the dark-first design pristine
// and lets us ship the toggle as a purely additive change. Overrides
// target the real token names used in app.css (`--color-void`,
// `--color-text`, `--color-bright`, `--color-dim`, `--color-muted`,
// `--color-surface`, etc.) plus halve the glow shadows so neon accents
// don't wash out on a slate-50 canvas.
import { writable, derived, get, type Readable } from 'svelte/store';
import { browser } from '$app/environment';
export type Theme = 'dark' | 'light' | 'auto';
export type ResolvedTheme = 'dark' | 'light';
const STORAGE_KEY = 'vestige.theme';
const STYLE_ELEMENT_ID = 'vestige-theme-light';
/** User preference — 'dark' | 'light' | 'auto'. Persists to localStorage. */
export const theme = writable<Theme>('dark');
/**
* System preference at this moment tracked via matchMedia and kept in
* sync by the listener wired up in `initTheme`. Defaults to 'dark' so
* SSR/first paint matches the dark-first design.
*/
const systemPrefersDark = writable<boolean>(true);
/**
* The concrete theme after resolving 'auto' matchMedia. This is what
* actually gets written to `document.documentElement.dataset.theme`.
*/
export const resolvedTheme: Readable<ResolvedTheme> = derived(
[theme, systemPrefersDark],
([$theme, $prefersDark]) => {
if ($theme === 'auto') return $prefersDark ? 'dark' : 'light';
return $theme;
}
);
/** Runtime guard TypeScript callers are already narrowed, but the store is
* also exposed via the dashboard window for devtools / demo sequences.
* We silently ignore unknown values rather than throwing so a fat-finger
* console poke can't wedge the UI. */
function isValidTheme(v: unknown): v is Theme {
return v === 'dark' || v === 'light' || v === 'auto';
}
/** Public setter. Also persists to localStorage. Invalid inputs are ignored. */
export function setTheme(next: Theme): void {
if (!isValidTheme(next)) return;
theme.set(next);
if (browser) {
try {
localStorage.setItem(STORAGE_KEY, next);
} catch {
// Private mode / disabled storage — silent no-op.
}
}
}
/** Cycle dark → light → auto → dark. Used by the ThemeToggle button. */
export function cycleTheme(): void {
const current = get(theme);
const next: Theme = current === 'dark' ? 'light' : current === 'light' ? 'auto' : 'dark';
setTheme(next);
}
/**
* Injects the light-mode variable overrides into <head>. Idempotent
* safe to call multiple times. We target the real tokens from app.css
* and halve the glow intensity so bioluminescent accents remain readable
* but don't bloom on a pale canvas.
*/
function ensureLightStylesheet(): void {
if (!browser) return;
if (document.getElementById(STYLE_ELEMENT_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ELEMENT_ID;
style.textContent = `
/* Vestige light-mode overrides injected by theme.ts.
* Activated by [data-theme='light'] on <html>.
* Tokens mirror the real names used in app.css so the cascade stays clean. */
[data-theme='light'] {
/* Core surface palette (slate scale) */
--color-void: #f8fafc; /* slate-50 — page background */
--color-abyss: #f1f5f9; /* slate-100 */
--color-deep: #e2e8f0; /* slate-200 */
--color-surface: #f1f5f9; /* slate-100 */
--color-elevated: #e2e8f0; /* slate-200 */
--color-subtle: #cbd5e1; /* slate-300 */
--color-muted: #94a3b8; /* slate-400 */
--color-dim: #475569; /* slate-600 */
--color-text: #0f172a; /* slate-900 */
--color-bright: #020617; /* slate-950 */
}
/* Baseline body/html wiring app.css sets these against the dark
* tokens; we just let the variables do the work. Reassert for clarity. */
[data-theme='light'] html,
html[data-theme='light'] {
background: var(--color-void);
color: var(--color-text);
}
/* Glass surfaces recompose on a light canvas. The original alphas
* are tuned for dark; invert-and-tint for light so panels still read
* as elevated instead of vanishing. */
[data-theme='light'] .glass {
background: rgba(255, 255, 255, 0.65);
border: 1px solid rgba(99, 102, 241, 0.12);
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, 0.6),
0 4px 24px rgba(15, 23, 42, 0.08);
}
[data-theme='light'] .glass-subtle {
background: rgba(255, 255, 255, 0.55);
border: 1px solid rgba(99, 102, 241, 0.1);
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, 0.5),
0 2px 12px rgba(15, 23, 42, 0.06);
}
[data-theme='light'] .glass-sidebar {
background: rgba(248, 250, 252, 0.82);
border-right: 1px solid rgba(99, 102, 241, 0.14);
box-shadow:
inset -1px 0 0 0 rgba(255, 255, 255, 0.4),
4px 0 24px rgba(15, 23, 42, 0.08);
}
[data-theme='light'] .glass-panel {
background: rgba(255, 255, 255, 0.75);
border: 1px solid rgba(99, 102, 241, 0.14);
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, 0.5),
0 8px 32px rgba(15, 23, 42, 0.1);
}
/* Halve glow intensity neon accents stay recognizable without
* washing out on slate-50. */
[data-theme='light'] .glow-synapse {
box-shadow: 0 0 10px rgba(99, 102, 241, 0.15), 0 0 30px rgba(99, 102, 241, 0.05);
}
[data-theme='light'] .glow-dream {
box-shadow: 0 0 10px rgba(168, 85, 247, 0.15), 0 0 30px rgba(168, 85, 247, 0.05);
}
[data-theme='light'] .glow-memory {
box-shadow: 0 0 10px rgba(59, 130, 246, 0.15), 0 0 30px rgba(59, 130, 246, 0.05);
}
/* Ambient orbs are gorgeous on black and blinding on white. Tame them. */
[data-theme='light'] .ambient-orb {
opacity: 0.18;
filter: blur(100px);
}
/* Scrollbar recolor for the lighter surface. */
[data-theme='light'] ::-webkit-scrollbar-thumb {
background: #cbd5e1;
}
[data-theme='light'] ::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
`;
document.head.appendChild(style);
}
/** Apply the resolved theme to <html> so CSS selectors activate. */
function applyDocumentAttribute(resolved: ResolvedTheme): void {
if (!browser) return;
document.documentElement.dataset.theme = resolved;
}
let mediaQuery: MediaQueryList | null = null;
let mediaListener: ((e: MediaQueryListEvent) => void) | null = null;
let themeUnsub: (() => void) | null = null;
let resolvedUnsub: (() => void) | null = null;
/**
* Boot the theme system. Call once from +layout.svelte onMount.
* Idempotent safe to call repeatedly; subsequent calls are no-ops.
* Returns a teardown fn for tests / HMR.
*/
export function initTheme(): () => void {
if (!browser) return () => {};
// Tear down any prior init so repeated calls don't leak listeners or
// subscriptions. This is the hot-reload / double-mount safety net.
if (mediaQuery && mediaListener) {
mediaQuery.removeEventListener('change', mediaListener);
}
resolvedUnsub?.();
themeUnsub?.();
mediaQuery = null;
mediaListener = null;
resolvedUnsub = null;
themeUnsub = null;
ensureLightStylesheet();
// 1. Read persisted preference.
let saved: Theme = 'dark';
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === 'dark' || raw === 'light' || raw === 'auto') saved = raw;
} catch {
// ignore
}
theme.set(saved);
// 2. Prime system preference + attach matchMedia listener.
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
systemPrefersDark.set(mediaQuery.matches);
mediaListener = (e: MediaQueryListEvent) => systemPrefersDark.set(e.matches);
mediaQuery.addEventListener('change', mediaListener);
// 3. Apply the currently-resolved theme and subscribe for future changes.
applyDocumentAttribute(get(resolvedTheme));
resolvedUnsub = resolvedTheme.subscribe(applyDocumentAttribute);
// Silence the unused-import lint on `theme` — already used above,
// but also keep a subscription handle for teardown symmetry.
themeUnsub = theme.subscribe(() => {});
return () => {
if (mediaQuery && mediaListener) {
mediaQuery.removeEventListener('change', mediaListener);
}
mediaQuery = null;
mediaListener = null;
resolvedUnsub?.();
themeUnsub?.();
resolvedUnsub = null;
themeUnsub = null;
};
}