mirror of
https://github.com/samvallad33/vestige.git
synced 2026-05-02 04:12:36 +02:00
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:
parent
7441b3cdfe
commit
50869e96ff
47 changed files with 13756 additions and 6 deletions
372
apps/dashboard/src/lib/components/ActivationNetwork.svelte
Normal file
372
apps/dashboard/src/lib/components/ActivationNetwork.svelte
Normal 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>
|
||||
312
apps/dashboard/src/lib/components/AmbientAwarenessStrip.svelte
Normal file
312
apps/dashboard/src/lib/components/AmbientAwarenessStrip.svelte
Normal 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>
|
||||
421
apps/dashboard/src/lib/components/ContradictionArcs.svelte
Normal file
421
apps/dashboard/src/lib/components/ContradictionArcs.svelte
Normal 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 (>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>
|
||||
211
apps/dashboard/src/lib/components/DreamInsightCard.svelte
Normal file
211
apps/dashboard/src/lib/components/DreamInsightCard.svelte
Normal 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>
|
||||
539
apps/dashboard/src/lib/components/DreamStageReplay.svelte
Normal file
539
apps/dashboard/src/lib/components/DreamStageReplay.svelte
Normal 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>
|
||||
192
apps/dashboard/src/lib/components/DuplicateCluster.svelte
Normal file
192
apps/dashboard/src/lib/components/DuplicateCluster.svelte
Normal 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}
|
||||
157
apps/dashboard/src/lib/components/EvidenceCard.svelte
Normal file
157
apps/dashboard/src/lib/components/EvidenceCard.svelte
Normal 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>
|
||||
344
apps/dashboard/src/lib/components/FSRSCalendar.svelte
Normal file
344
apps/dashboard/src/lib/components/FSRSCalendar.svelte
Normal 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>
|
||||
174
apps/dashboard/src/lib/components/ImportanceRadar.svelte
Normal file
174
apps/dashboard/src/lib/components/ImportanceRadar.svelte
Normal 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>
|
||||
185
apps/dashboard/src/lib/components/MemoryAuditTrail.svelte
Normal file
185
apps/dashboard/src/lib/components/MemoryAuditTrail.svelte
Normal 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>
|
||||
251
apps/dashboard/src/lib/components/PatternTransferHeatmap.svelte
Normal file
251
apps/dashboard/src/lib/components/PatternTransferHeatmap.svelte
Normal 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>
|
||||
259
apps/dashboard/src/lib/components/ReasoningChain.svelte
Normal file
259
apps/dashboard/src/lib/components/ReasoningChain.svelte
Normal 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>
|
||||
175
apps/dashboard/src/lib/components/ThemeToggle.svelte
Normal file
175
apps/dashboard/src/lib/components/ThemeToggle.svelte
Normal 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>
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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('');
|
||||
});
|
||||
});
|
||||
|
|
@ -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]/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
255
apps/dashboard/src/lib/components/__tests__/EvidenceCard.test.ts
Normal file
255
apps/dashboard/src/lib/components/__tests__/EvidenceCard.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
311
apps/dashboard/src/lib/components/__tests__/FSRSCalendar.test.ts
Normal file
311
apps/dashboard/src/lib/components/__tests__/FSRSCalendar.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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-…');
|
||||
});
|
||||
});
|
||||
|
|
@ -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}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
237
apps/dashboard/src/lib/components/activation-helpers.ts
Normal file
237
apps/dashboard/src/lib/components/activation-helpers.ts
Normal 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();
|
||||
}
|
||||
293
apps/dashboard/src/lib/components/audit-trail-helpers.ts
Normal file
293
apps/dashboard/src/lib/components/audit-trail-helpers.ts
Normal 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
|
||||
};
|
||||
}
|
||||
192
apps/dashboard/src/lib/components/awareness-helpers.ts
Normal file
192
apps/dashboard/src/lib/components/awareness-helpers.ts
Normal 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;
|
||||
}
|
||||
210
apps/dashboard/src/lib/components/contradiction-helpers.ts
Normal file
210
apps/dashboard/src/lib/components/contradiction-helpers.ts
Normal 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;
|
||||
}
|
||||
155
apps/dashboard/src/lib/components/dream-helpers.ts
Normal file
155
apps/dashboard/src/lib/components/dream-helpers.ts
Normal 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;
|
||||
}
|
||||
149
apps/dashboard/src/lib/components/duplicates-helpers.ts
Normal file
149
apps/dashboard/src/lib/components/duplicates-helpers.ts
Normal 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);
|
||||
}
|
||||
226
apps/dashboard/src/lib/components/importance-helpers.ts
Normal file
226
apps/dashboard/src/lib/components/importance-helpers.ts
Normal 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));
|
||||
}
|
||||
178
apps/dashboard/src/lib/components/patterns-helpers.ts
Normal file
178
apps/dashboard/src/lib/components/patterns-helpers.ts
Normal 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;
|
||||
}
|
||||
229
apps/dashboard/src/lib/components/reasoning-helpers.ts
Normal file
229
apps/dashboard/src/lib/components/reasoning-helpers.ts
Normal 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;
|
||||
}
|
||||
161
apps/dashboard/src/lib/components/schedule-helpers.ts
Normal file
161
apps/dashboard/src/lib/components/schedule-helpers.ts
Normal 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 };
|
||||
}
|
||||
496
apps/dashboard/src/lib/stores/__tests__/theme.test.ts
Normal file
496
apps/dashboard/src/lib/stores/__tests__/theme.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
254
apps/dashboard/src/lib/stores/theme.ts
Normal file
254
apps/dashboard/src/lib/stores/theme.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue