diff --git a/package-lock.json b/package-lock.json
index 6ef5da8..7ae8122 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,7 @@
"class-variance-authority": "^0.7.1",
"d3-force": "^3.0.0",
"electron": "^39.8.5",
+ "framer-motion": "^12.42.2",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
@@ -3183,6 +3184,33 @@
}
}
},
+ "node_modules/framer-motion": {
+ "version": "12.42.2",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz",
+ "integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^12.42.2",
+ "motion-utils": "^12.39.0",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
@@ -3709,6 +3737,21 @@
"node": ">=4"
}
},
+ "node_modules/motion-dom": {
+ "version": "12.42.2",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz",
+ "integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^12.39.0"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.39.0",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
+ "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
diff --git a/package.json b/package.json
index aa9420a..ebb2734 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
"class-variance-authority": "^0.7.1",
"d3-force": "^3.0.0",
"electron": "^39.8.5",
+ "framer-motion": "^12.42.2",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index d7d9e40..893f085 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -413,6 +413,17 @@ export default function App({ host }: ShadowAgentAppProps) {
const GraphCanvas = rendererSurfaceAdapter.GraphCanvas;
const TimelineSurface = rendererSurfaceAdapter.Timeline;
const ShadowPanelSurface = rendererSurfaceAdapter.ShadowPanel;
+ // ExhibitStage is the primary main-area surface. Guarded so adapter mocks in
+ // existing tests (which omit it) keep rendering the legacy panels unchanged.
+ const ExhibitStageSurface = rendererSurfaceAdapter.ExhibitStage;
+
+ const liveGraphNode = (
+
+ );
return (
@@ -490,18 +501,22 @@ export default function App({ host }: ShadowAgentAppProps) {
{snapshot?.state.currentObjective ?? 'Waiting for snapshot data'}
-
-
-
-
-
-
-
-
+ {ExhibitStageSurface ? (
+
+ ) : null}
+
+
+ Session panels
+
+ {ExhibitStageSurface ? null : (
+
+ {liveGraphNode}
+
+ )}
+
+
+
+
+
+
+
);
diff --git a/src/renderer/exhibits/ActivityNarrative.tsx b/src/renderer/exhibits/ActivityNarrative.tsx
new file mode 100644
index 0000000..aba2e09
--- /dev/null
+++ b/src/renderer/exhibits/ActivityNarrative.tsx
@@ -0,0 +1,115 @@
+/**
+ * Exhibit 02 — Activity Narrative (the Folding Timeline). Ported from prototype
+ * view 02: interesting moments ONLY, folding out of a central spine, alternating
+ * top/bottom. Connectors between consecutive beats are colored by whether the
+ * storyline thread continues; the trace advances beat by beat while idle.
+ */
+import { Fragment, useEffect, useMemo, useState } from 'react';
+import { motion, useReducedMotion } from 'framer-motion';
+import { Chip, Frame, cx } from './primitives';
+import type { ActivityNarrativePayload, ExhibitArtifactOf } from './types';
+
+export interface ActivityNarrativeProps {
+ payload: ActivityNarrativePayload;
+ artifact: ExhibitArtifactOf<'activity_narrative'>;
+}
+
+export default function ActivityNarrative({ payload }: ActivityNarrativeProps) {
+ const reduce = useReducedMotion();
+ const { beats, threads } = payload;
+ const [active, setActive] = useState(beats.length - 1);
+ const threadColor = useMemo(
+ () => Object.fromEntries(threads.map((t) => [t.id, t.color ?? 'rgba(56,189,248,0.65)'])),
+ [threads]
+ );
+
+ const step = beats.length > 1 ? Math.min(14.5, 80 / (beats.length - 1)) : 14.5;
+
+ useEffect(() => {
+ if (reduce || beats.length <= 1) return;
+ const id = window.setInterval(() => {
+ setActive((prev) => (prev + 1) % beats.length);
+ }, 2600);
+ return () => window.clearInterval(id);
+ }, [beats.length, reduce]);
+
+ return (
+
+
+ interesting moments only
+ alternating tile foldout
+
+
+
+
+
+
+ {beats.map((beat, idx) => {
+ const x = 10 + idx * step;
+ const activeNow = idx <= active;
+ const selected = idx === active;
+ return (
+
+
+
+ {beat.at}
+ {beat.title}
+ {beat.body}
+ thread · {beat.thread}
+
+
+ );
+ })}
+
+
+
+ scrub interesting beats
+ {beats[active]?.at}
+
+
setActive(Number(e.target.value))}
+ />
+
+
+ );
+}
diff --git a/src/renderer/exhibits/ConcernSnapshot.tsx b/src/renderer/exhibits/ConcernSnapshot.tsx
new file mode 100644
index 0000000..0295144
--- /dev/null
+++ b/src/renderer/exhibits/ConcernSnapshot.tsx
@@ -0,0 +1,94 @@
+/**
+ * Exhibit 04 — Concern Snapshot. Ported from prototype view 04 (Architecture
+ * Snapshot), re-aimed at agent-session observation: the work abstracted into
+ * 5-8 *concerns* (not directories) with human-language roles, heat-classed
+ * boxes, and flows between them. Hovering a concern selects it.
+ */
+import { useMemo, useState } from 'react';
+import { motion, useReducedMotion } from 'framer-motion';
+import { Chip, Frame, cx, curvedPath } from './primitives';
+import type { ConcernSnapshotPayload, ExhibitArtifactOf, HeatLevel } from './types';
+
+export interface ConcernSnapshotProps {
+ payload: ConcernSnapshotPayload;
+ artifact: ExhibitArtifactOf<'concern_snapshot'>;
+}
+
+const HEAT_WIDTH: Record
= {
+ low: '24%',
+ medium: '48%',
+ high: '74%',
+ 'very-high': '92%',
+};
+
+export default function ConcernSnapshot({ payload }: ConcernSnapshotProps) {
+ const reduce = useReducedMotion();
+ const { concerns, flows } = payload;
+ const byId = useMemo(() => Object.fromEntries(concerns.map((c) => [c.id, c])), [concerns]);
+ const [selected, setSelected] = useState(concerns[0]?.id ?? '');
+ const current = byId[selected] ?? concerns[0];
+
+ return (
+
+
+ 5-8 concern boxes
+ labels say what it does
+
+
+
+
+ {concerns.map((concern) => (
+ setSelected(concern.id)}
+ onFocus={() => setSelected(concern.id)}
+ className={cx(
+ 'concern-box',
+ `concern-box--${concern.heat}`,
+ selected === concern.id && 'concern-box--selected'
+ )}
+ style={{ left: `${concern.x}%`, top: `${concern.y}%`, width: `${concern.w}%`, height: `${concern.h}%` }}
+ >
+ concern
+ {concern.title}
+ {concern.role}
+
+ ))}
+
+ {current ? (
+
+
selected concern
+
{current.title}
+
{current.role}
+
+
+
+
recent activity heat · {current.heat}
+
+ ) : null}
+
+ );
+}
diff --git a/src/renderer/exhibits/ExhibitStage.tsx b/src/renderer/exhibits/ExhibitStage.tsx
new file mode 100644
index 0000000..a76f9b2
--- /dev/null
+++ b/src/renderer/exhibits/ExhibitStage.tsx
@@ -0,0 +1,256 @@
+/**
+ * ExhibitStage — the museum floor.
+ *
+ * Left rail lists the gallery (type icon, title, status pill, relevance bar,
+ * narrative preview). The main Frame shows the active exhibit with an
+ * AnimatePresence swap. A glass commentary card carries the artifact's full
+ * narrative + envelope metadata. Idle cycling advances by relevance-weighted
+ * order every ~8s and pauses on hover/interaction (a visible pill plus manual
+ * prev/next). Retired artifacts collapse into an archive shelf. `live_graph`
+ * renders the existing canvas inside the Frame via the `liveGraph` slot.
+ */
+import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
+import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
+import { Frame, cx, stageSwapVariants } from './primitives';
+import RelationshipDag from './RelationshipDag';
+import ActivityNarrative from './ActivityNarrative';
+import Walkthrough from './Walkthrough';
+import ConcernSnapshot from './ConcernSnapshot';
+import Momentum from './Momentum';
+import Seismograph from './Seismograph';
+import ThermalMap from './ThermalMap';
+import fixtureGallery from './fixture-gallery';
+import {
+ isActivityNarrative,
+ isConcernSnapshot,
+ isLiveGraph,
+ isMomentum,
+ isRelationshipDag,
+ isSeismograph,
+ isThermalMap,
+ isWalkthrough,
+ type ExhibitArtifact,
+ type ExhibitType,
+} from './types';
+
+const CYCLE_MS = 8000;
+
+const TYPE_LABEL: Record = {
+ relationship_dag: 'Relationship DAG',
+ activity_narrative: 'Activity Narrative',
+ walkthrough: 'Walkthrough',
+ concern_snapshot: 'Concern Snapshot',
+ momentum: 'Momentum',
+ seismograph: 'Seismograph',
+ thermal_map: 'Thermal Map',
+ live_graph: 'Live Graph',
+};
+
+function ExhibitIcon({ type }: { type: ExhibitType }) {
+ const common = { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const };
+ switch (type) {
+ case 'relationship_dag':
+ return ();
+ case 'activity_narrative':
+ return ();
+ case 'walkthrough':
+ return ();
+ case 'concern_snapshot':
+ return ();
+ case 'momentum':
+ return ();
+ case 'seismograph':
+ return ();
+ case 'thermal_map':
+ return ();
+ case 'live_graph':
+ default:
+ return ();
+ }
+}
+
+function ActiveExhibit({ artifact, liveGraph }: { artifact: ExhibitArtifact; liveGraph?: ReactNode }) {
+ if (isRelationshipDag(artifact)) return ;
+ if (isActivityNarrative(artifact)) return ;
+ if (isWalkthrough(artifact)) return ;
+ if (isConcernSnapshot(artifact)) return ;
+ if (isMomentum(artifact)) return ;
+ if (isSeismograph(artifact)) return ;
+ if (isThermalMap(artifact)) return ;
+ if (isLiveGraph(artifact)) {
+ return (
+
+
+ live topology
+
+
+ {liveGraph ??
Live graph is unavailable in this context.
}
+
+
+ );
+ }
+ return null;
+}
+
+export interface ExhibitStageProps {
+ artifacts?: ExhibitArtifact[];
+ /** Rendered inside the `live_graph` exhibit (the existing CanvasRenderer). */
+ liveGraph?: ReactNode;
+}
+
+export default function ExhibitStage({ artifacts = fixtureGallery, liveGraph }: ExhibitStageProps) {
+ const reduce = useReducedMotion();
+
+ const active = useMemo(
+ () => artifacts.filter((a) => a.status !== 'retired').sort((a, b) => b.relevance - a.relevance),
+ [artifacts]
+ );
+ const retired = useMemo(() => artifacts.filter((a) => a.status === 'retired'), [artifacts]);
+
+ const [index, setIndex] = useState(0);
+ const [paused, setPaused] = useState(false);
+ const [cycles, setCycles] = useState>({});
+ const lastShownRef = useRef(null);
+
+ const safeIndex = active.length === 0 ? 0 : index % active.length;
+ const current = active[safeIndex];
+
+ useEffect(() => {
+ if (index >= active.length && active.length > 0) {
+ setIndex(0);
+ }
+ }, [active.length, index]);
+
+ useEffect(() => {
+ if (paused || reduce || active.length <= 1) return undefined;
+ const id = window.setInterval(() => {
+ setIndex((prev) => (prev + 1) % active.length);
+ }, CYCLE_MS);
+ return () => window.clearInterval(id);
+ }, [paused, reduce, active.length]);
+
+ // Track how many times each artifact has been the active exhibit.
+ useEffect(() => {
+ if (!current || lastShownRef.current === current.id) return;
+ lastShownRef.current = current.id;
+ setCycles((prev) => ({ ...prev, [current.id]: (prev[current.id] ?? 0) + 1 }));
+ }, [current]);
+
+ const goTo = (next: number) => {
+ if (active.length === 0) return;
+ setIndex(((next % active.length) + active.length) % active.length);
+ };
+
+ return (
+ setPaused(true)}
+ onMouseLeave={() => setPaused(false)}
+ >
+
+
+
+
+
+
{current ? TYPE_LABEL[current.exhibitType] : 'Exhibit floor'}
+
{current?.title ?? 'No exhibits'}
+
+
+
+ {paused ? 'paused' : 'cycling'}
+
+
+
+
+
+
+
+
+ {current ? (
+
+
+
+ ) : null}
+
+
+ {current ? (
+
+
Agent commentary
+
{current.narrative}
+
+
+
Relevance
+
{current.relevance.toFixed(2)}
+
+
+
Status
+
{current.status}
+
+
+
Cycles shown
+
{cycles[current.id] ?? 1}
+
+
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/src/renderer/exhibits/Momentum.tsx b/src/renderer/exhibits/Momentum.tsx
new file mode 100644
index 0000000..764d78c
--- /dev/null
+++ b/src/renderer/exhibits/Momentum.tsx
@@ -0,0 +1,107 @@
+/**
+ * Exhibit 05 — Momentum. Ported from prototype view 05: a 0-100 gauge (arcPath +
+ * animated needle), stat tiles, inferred what's-next items with evidence and
+ * confidence bars, and the curator's own refresh/retire recommendations
+ * (retirement reasons render, per the plan).
+ */
+import { motion, useReducedMotion } from 'framer-motion';
+import { Chip, Frame, arcPath, cx } from './primitives';
+import type { ExhibitArtifactOf, MomentumPayload } from './types';
+
+export interface MomentumProps {
+ payload: MomentumPayload;
+ artifact: ExhibitArtifactOf<'momentum'>;
+}
+
+export default function Momentum({ payload }: MomentumProps) {
+ const reduce = useReducedMotion();
+ const { value, label, stats, next, curation } = payload;
+ const clamped = Math.max(0, Math.min(100, value));
+ const angle = -120 + (clamped / 100) * 240;
+ const needleX = 120 + 82 * Math.cos(((angle - 90) * Math.PI) / 180);
+ const needleY = 120 + 82 * Math.sin(((angle - 90) * Math.PI) / 180);
+ // Split the arc so the filled portion tracks the value.
+ const splitDeg = -120 + (clamped / 100) * 240;
+
+ return (
+
+
+ single-glance state
+ what's next inferred
+
+
+
+
+
+
+
momentum indicator
+
{label}
+
+
+
+
+
+ {stats.map((stat) => (
+
+
{stat.label}
+
{stat.value}
+
+ ))}
+
+
+
+
+
+
what's next
+
+ {next.map((item) => (
+
+
+
+
{item.title}
+
{item.evidence}
+
+
{item.confidence}%
+
+
+
+
+
+ ))}
+
+
+
+
+ {curation.length > 0 ? (
+
+
curation hooks
+
+ {curation.map((rec) => (
+
+
{rec.action} candidate
+
{rec.artifactId}
+
{rec.reason}
+
+ ))}
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/renderer/exhibits/RelationshipDag.tsx b/src/renderer/exhibits/RelationshipDag.tsx
new file mode 100644
index 0000000..0097634
--- /dev/null
+++ b/src/renderer/exhibits/RelationshipDag.tsx
@@ -0,0 +1,112 @@
+/**
+ * Exhibit 01 — Relationship DAG. Ported from prototype view 01: a curated
+ * subgraph of the session's noteworthy entities (8-20 nodes max). Edges draw on
+ * left-to-right, staggered by index; hovering a node focuses it and surfaces
+ * its curator note in the in-frame focus card.
+ */
+import { useMemo, useState } from 'react';
+import { motion, useReducedMotion } from 'framer-motion';
+import { Chip, ExhibitNode, Frame, curvedPath } from './primitives';
+import type { ExhibitArtifactOf, RelationshipDagPayload } from './types';
+
+export interface RelationshipDagProps {
+ payload: RelationshipDagPayload;
+ artifact: ExhibitArtifactOf<'relationship_dag'>;
+}
+
+export default function RelationshipDag({ payload, artifact }: RelationshipDagProps) {
+ const reduce = useReducedMotion();
+ const { nodes, edges, focusNodeId } = payload;
+ const [selected, setSelected] = useState(focusNodeId);
+ const byId = useMemo(() => Object.fromEntries(nodes.map((n) => [n.id, n])), [nodes]);
+ const selectedNode = byId[selected] ?? nodes.find((n) => n.id === focusNodeId) ?? nodes[0];
+
+ return (
+
+
+ curated graph
+ 8-20 noteworthy nodes max
+
+
+
+
+ {nodes.map((node) => (
+ setSelected(node.id)}
+ />
+ ))}
+
+ {selectedNode ? (
+
+ focused node
+ {selectedNode.title}
+ {selectedNode.note}
+
+
+
Kind
+
{selectedNode.kind}
+
+
+
Relevance
+
{artifact.relevance.toFixed(2)}
+
+
+
State
+
{artifact.status}
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/renderer/exhibits/Seismograph.tsx b/src/renderer/exhibits/Seismograph.tsx
new file mode 100644
index 0000000..1145b25
--- /dev/null
+++ b/src/renderer/exhibits/Seismograph.tsx
@@ -0,0 +1,154 @@
+/**
+ * Exhibit 06 — Seismograph. Designed fresh in the Frame language (creative alt
+ * 4A): the session as a seismic trace along a horizontal time axis. Events
+ * deflect the line vertically, scaled and kind-colored by magnitude; failures
+ * and aborts spike hard, quiet stretches read flat. The baseline trace draws on
+ * left-to-right, and named tremors get annotation labels.
+ */
+import { useMemo } from 'react';
+import { motion, useReducedMotion } from 'framer-motion';
+import { Chip, Frame, accentFor } from './primitives';
+import type { ExhibitArtifactOf, SeismographPayload, SeismographTremor } from './types';
+
+export interface SeismographProps {
+ payload: SeismographPayload;
+ artifact: ExhibitArtifactOf<'seismograph'>;
+}
+
+const BASELINE = 52;
+const DEFLECT = 34;
+
+function spikeColor(kind: SeismographTremor['kind']): string {
+ switch (kind) {
+ case 'failure':
+ case 'abort':
+ return 'rgba(248,113,113,0.9)';
+ case 'merge':
+ return 'rgba(52,211,153,0.9)';
+ case 'quiet':
+ return 'rgba(148,163,184,0.55)';
+ default:
+ return accentFor(kind).from;
+ }
+}
+
+export default function Seismograph({ payload }: SeismographProps) {
+ const reduce = useReducedMotion();
+ const { trace, annotations, windowMinutes } = payload;
+
+ const points = useMemo(() => {
+ const n = Math.max(1, trace.length);
+ return trace.map((tremor, idx) => {
+ const x = trace.length === 1 ? 50 : 8 + (idx / (n - 1)) * 84;
+ const peak = BASELINE - Math.max(-1, Math.min(1, tremor.magnitude)) * DEFLECT;
+ return { ...tremor, x, peak };
+ });
+ }, [trace]);
+
+ // A seismic polyline: baseline between events, sharp deflection at each event.
+ const tracePath = useMemo(() => {
+ if (points.length === 0) return `M 4 ${BASELINE} L 96 ${BASELINE}`;
+ let d = `M 4 ${BASELINE}`;
+ for (const p of points) {
+ d += ` L ${(p.x - 1.4).toFixed(2)} ${BASELINE} L ${p.x.toFixed(2)} ${p.peak.toFixed(2)} L ${(p.x + 1.4).toFixed(2)} ${BASELINE}`;
+ }
+ d += ` L 96 ${BASELINE}`;
+ return d;
+ }, [points]);
+
+ const annotationByAt = useMemo(
+ () => Object.fromEntries(annotations.map((a) => [a.at, a.text])),
+ [annotations]
+ );
+
+ return (
+
+
+ seismic trace
+ {windowMinutes} min window
+
+
+
+
+ {/* spike + annotation labels */}
+ {points.map((p, idx) => {
+ const annotation = p.label ?? annotationByAt[p.at];
+ const above = p.peak <= BASELINE;
+ return (
+
+ {p.at}
+ {annotation ? {annotation} : null}
+
+ );
+ })}
+
+
+ start
+ time →
+ now
+
+
+ );
+}
diff --git a/src/renderer/exhibits/ThermalMap.tsx b/src/renderer/exhibits/ThermalMap.tsx
new file mode 100644
index 0000000..ff1a87a
--- /dev/null
+++ b/src/renderer/exhibits/ThermalMap.tsx
@@ -0,0 +1,181 @@
+/**
+ * Exhibit 07 — Thermal Map. Designed fresh in the Frame language (creative alt
+ * 5A): file/subsystem churn as a squarified treemap. Cell area is the model's
+ * attention weight; cell color lerps cool blue (stable) -> hot red (volatile)
+ * by heat 0..1. The hottest cell gets a white-hot border and a "why" callout.
+ */
+import { useMemo } from 'react';
+import { motion, useReducedMotion } from 'framer-motion';
+import { Chip, Frame } from './primitives';
+import type { ExhibitArtifactOf, ThermalCell, ThermalMapPayload } from './types';
+
+export interface ThermalMapProps {
+ payload: ThermalMapPayload;
+ artifact: ExhibitArtifactOf<'thermal_map'>;
+}
+
+interface Rect {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+}
+
+const HEAT_STOPS: Array<{ at: number; rgb: [number, number, number] }> = [
+ { at: 0, rgb: [56, 189, 248] }, // cool blue
+ { at: 0.5, rgb: [245, 158, 11] }, // amber
+ { at: 1, rgb: [248, 113, 113] }, // hot red
+];
+
+function heatColor(heat: number, alpha = 0.85): string {
+ const h = Math.max(0, Math.min(1, heat));
+ let lo = HEAT_STOPS[0];
+ let hi = HEAT_STOPS[HEAT_STOPS.length - 1];
+ for (let i = 0; i < HEAT_STOPS.length - 1; i++) {
+ if (h >= HEAT_STOPS[i].at && h <= HEAT_STOPS[i + 1].at) {
+ lo = HEAT_STOPS[i];
+ hi = HEAT_STOPS[i + 1];
+ break;
+ }
+ }
+ const span = hi.at - lo.at || 1;
+ const t = (h - lo.at) / span;
+ const c = lo.rgb.map((v, i) => Math.round(v + (hi.rgb[i] - v) * t));
+ return `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${alpha})`;
+}
+
+function worstRatio(areas: number[], side: number): number {
+ const sum = areas.reduce((a, b) => a + b, 0);
+ const max = Math.max(...areas);
+ const min = Math.min(...areas);
+ return Math.max((side * side * max) / (sum * sum), (sum * sum) / (side * side * min));
+}
+
+/** Classic squarified treemap over a percentage rect. `values` are unscaled weights. */
+function squarify(values: number[], rect: Rect): Rect[] {
+ const result: Rect[] = new Array(values.length);
+ const totalWeight = values.reduce((a, b) => a + b, 0) || 1;
+ const areaScale = (rect.w * rect.h) / totalWeight;
+ const areas = values.map((v) => v * areaScale);
+ const order = values.map((_, i) => i).sort((a, b) => areas[b] - areas[a]);
+
+ let x = rect.x;
+ let y = rect.y;
+ let w = rect.w;
+ let h = rect.h;
+ let idx = 0;
+
+ while (idx < order.length) {
+ const side = Math.min(w, h);
+ const row: number[] = [];
+ let best = Infinity;
+ while (idx < order.length) {
+ const candidate = [...row, order[idx]];
+ const ratio = worstRatio(candidate.map((k) => areas[k]), side);
+ if (row.length === 0 || ratio <= best) {
+ row.push(order[idx]);
+ best = ratio;
+ idx++;
+ } else {
+ break;
+ }
+ }
+ const rowSum = row.reduce((s, k) => s + areas[k], 0);
+ if (w <= h) {
+ const rowH = rowSum / w;
+ let cx = x;
+ for (const k of row) {
+ const cw = (areas[k] / rowSum) * w;
+ result[k] = { x: cx, y, w: cw, h: rowH };
+ cx += cw;
+ }
+ y += rowH;
+ h -= rowH;
+ } else {
+ const rowW = rowSum / h;
+ let cy = y;
+ for (const k of row) {
+ const ch = (areas[k] / rowSum) * h;
+ result[k] = { x, y: cy, w: rowW, h: ch };
+ cy += ch;
+ }
+ x += rowW;
+ w -= rowW;
+ }
+ }
+ return result;
+}
+
+export default function ThermalMap({ payload }: ThermalMapProps) {
+ const reduce = useReducedMotion();
+ const { cells, hottest } = payload;
+
+ const layout = useMemo(() => {
+ const rect: Rect = { x: 4, y: 15, w: 92, h: 68 };
+ const rects = squarify(
+ cells.map((c) => Math.max(0.0001, c.weight)),
+ rect
+ );
+ return cells.map((cell, i) => ({ cell, rect: rects[i] }));
+ }, [cells]);
+
+ const hottestIdx = useMemo(() => {
+ const byPath = layout.findIndex((l) => l.cell.path === hottest.path);
+ if (byPath >= 0) return byPath;
+ let max = -1;
+ let maxIdx = 0;
+ layout.forEach((l, i) => {
+ if (l.cell.heat > max) {
+ max = l.cell.heat;
+ maxIdx = i;
+ }
+ });
+ return maxIdx;
+ }, [layout, hottest.path]);
+
+ return (
+
+
+ attention-weighted churn
+ cool stable · hot volatile
+
+
+ {layout.map(({ cell, rect }, i) => {
+ if (!rect) return null;
+ const isHottest = i === hottestIdx;
+ return (
+
+ {cell.label ?? cell.path}
+
+ {Math.round(cell.heat * 100)}
+
+
+ );
+ })}
+
+
+ hottest · {hottest.path}
+ {hottest.why}
+
+
+ );
+}
diff --git a/src/renderer/exhibits/Walkthrough.tsx b/src/renderer/exhibits/Walkthrough.tsx
new file mode 100644
index 0000000..0057aed
--- /dev/null
+++ b/src/renderer/exhibits/Walkthrough.tsx
@@ -0,0 +1,101 @@
+/**
+ * Exhibit 03 — Walkthrough. Ported from prototype view 03: one turn/task that
+ * deserves a deep dive, shown spatially. A central narrative card, satellite
+ * tool-call/commit cards drawn to it with solid connectors, touched-file tiles
+ * on dashed connectors, and plan-vs-outcome tags.
+ */
+import { motion, useReducedMotion } from 'framer-motion';
+import { Chip, Frame, cx, curvedPath } from './primitives';
+import type { ExhibitArtifactOf, WalkthroughPayload } from './types';
+
+export interface WalkthroughProps {
+ payload: WalkthroughPayload;
+ artifact: ExhibitArtifactOf<'walkthrough'>;
+}
+
+const CENTER = { x: 50, y: 48 };
+
+export default function Walkthrough({ payload, artifact }: WalkthroughProps) {
+ const reduce = useReducedMotion();
+ const { headline, body, tags, satellites, files } = payload;
+
+ return (
+
+
+ central narrative node
+ satellites + file tiles
+
+
+
+
+
+ {artifact.title}
+ {headline}
+ {body}
+
+ {tags.map((tag) => (
+
+ {tag.label}
+
+ ))}
+
+
+
+ {satellites.map((sat) => (
+
+ step
+ {sat.label}
+ {sat.note}
+
+ ))}
+
+ {files.map((file) => (
+
+ {file.label}
+
+ ))}
+
+ );
+}
diff --git a/src/renderer/exhibits/fixture-gallery.ts b/src/renderer/exhibits/fixture-gallery.ts
new file mode 100644
index 0000000..09af3b4
--- /dev/null
+++ b/src/renderer/exhibits/fixture-gallery.ts
@@ -0,0 +1,327 @@
+/**
+ * Fixture gallery — a hand-authored exhibition of the REAL Codex homelab
+ * coordinator session (see
+ * `tests/fixtures/transcripts/codex/rollout-2026-07-23-homelab-coordinator.ground-truth.md`).
+ *
+ * This lets the stage render the full exhibit floor with zero inference
+ * configured: it is the standing demo. Every narrative here is interpretive
+ * ("what it means / why it's on the floor"), never an event recap — that is the
+ * bar the curator prompt (PR-C) must clear when it authors these live.
+ *
+ * The session's true shape: a plan pivot (~08:39 → 09:04), PG18 recovery of
+ * eight databases, an exposed FORGEJO_INTERNAL_TOKEN left deliberately
+ * untouched, a GHCR-403 push stall, and an ending that is decidedly NOT "done."
+ */
+import type { ExhibitArtifact } from './types';
+
+const activityNarrative: ExhibitArtifact = {
+ id: 'homelab-narrative',
+ exhibitType: 'activity_narrative',
+ title: 'The database session, as a story',
+ narrative:
+ 'This session is not "some database work." It is the moment a homelab pivots from ' +
+ 'open-ended platform ambition to a disciplined, database-only recovery — and then ' +
+ 'runs out of runway before the payoff. The threads that matter are the plan pivot, ' +
+ 'the PG18 recovery, the token it chose not to touch, and the registry wall it hit. ' +
+ 'It is on the floor because the ending is unfinished: the story is still live.',
+ relevance: 0.95,
+ decayClass: 'slow',
+ createdAtEvent: 12,
+ status: 'active',
+ payload: {
+ threads: [
+ { id: 'plan', label: 'plan pivot', color: 'rgba(52,211,153,0.7)' },
+ { id: 'pg18', label: 'PG18 recovery', color: 'rgba(56,189,248,0.7)' },
+ { id: 'token', label: 'token exposure', color: 'rgba(251,146,60,0.75)' },
+ { id: 'ghcr', label: 'GHCR stall', color: 'rgba(232,121,249,0.7)' },
+ ],
+ beats: [
+ {
+ at: '08:39',
+ title: 'Scope narrows to databases',
+ body: 'The directive drops platform breadth and asks for one thing: stand up the databases, carrying every prior lesson. Ambition becomes a bounded problem.',
+ side: 'top',
+ thread: 'plan',
+ },
+ {
+ at: '09:04',
+ title: 'The plan becomes the authority',
+ body: '"Implement the plan." From here the plan written inside this session — not the repo doc — is the operative spec. The doc is only its projection.',
+ side: 'bottom',
+ thread: 'plan',
+ },
+ {
+ at: '09:36',
+ title: 'Eight databases, not the guesses',
+ body: 'config_fleet and cloud_inventory fall away as wrong guesses. The real PG18 surface resolves to eight named databases — the recovery target is finally concrete.',
+ side: 'top',
+ thread: 'pg18',
+ },
+ {
+ at: '10:12',
+ title: 'The registry says no (403)',
+ body: 'The image push stalls on a GHCR 403. Momentum parks against a credential wall, not a code defect — a distinction that decides what "next" even means.',
+ side: 'bottom',
+ thread: 'ghcr',
+ },
+ {
+ at: '10:48',
+ title: 'A token found, and left alone',
+ body: 'An exposed FORGEJO_INTERNAL_TOKEN surfaces. The agent neither echoes nor unilaterally rotates it — restraint is the correct move; rotation is deferred to Doppler.',
+ side: 'top',
+ thread: 'token',
+ },
+ {
+ at: '11:20',
+ title: 'Recovery proven, not shipped',
+ body: 'Physical PG18 recovery and FalkorDB compatibility are verified. The proof exists — but proof is not a cutover, and nothing is live yet.',
+ side: 'bottom',
+ thread: 'pg18',
+ },
+ {
+ at: '11:48',
+ title: 'Ends mid critical-path',
+ body: 'Custom image done; manifests and recovery tooling still being repaired; data-platform undeployed; restores and cutovers not started. The session stops, unfinished.',
+ side: 'top',
+ thread: 'plan',
+ },
+ ],
+ },
+};
+
+const relationshipDag: ExhibitArtifact = {
+ id: 'session-entities',
+ exhibitType: 'relationship_dag',
+ title: 'What this session was really about',
+ narrative:
+ 'Nine entities carry the whole session. The plan scopes everything; the custom PG18 ' +
+ 'image is the unblock; the eight databases are the payload; and the exposed token is ' +
+ 'the one urgent node that gates every downstream cutover. Read the urgent node first — ' +
+ 'it explains why "done" never arrived.',
+ relevance: 0.78,
+ decayClass: 'medium',
+ createdAtEvent: 20,
+ status: 'active',
+ payload: {
+ focusNodeId: 'token',
+ nodes: [
+ { id: 'plan', title: 'Database plan', subtitle: 'authored in-session · 08:39', kind: 'goal', x: 17, y: 22, note: 'The operative authority for the homelab data-platform work; the repo doc is just a projection of it.' },
+ { id: 'image', title: 'Custom PG18 image', subtitle: 'build complete', kind: 'artifact', x: 20, y: 70, note: 'The one thing that finished. It is the unblock for physical PG18 recovery — the rest of the graph waits on it.' },
+ { id: 'pg18', title: 'PG18 · 8 databases', subtitle: 'hangar, soil, moosegoose, …', kind: 'file', x: 45, y: 30, note: 'Exactly eight databases: hangar, llm_archiver, llm_measurements, market_live, moosegoose, techdeals_work, todo_cards, and historical soil.' },
+ { id: 'falkor', title: 'FalkorDB gate', subtitle: 'compatibility proven', kind: 'tool', x: 43, y: 74, note: 'Reconciled first, with PG18; PG19 is only allowed through this compatibility gate.' },
+ { id: 'token', title: 'FORGEJO_INTERNAL_TOKEN', subtitle: 'exposed · rotate via Doppler', kind: 'incident', x: 71, y: 18, urgent: true, note: 'Identified in-session and deliberately not repeated or rotated unilaterally. Until it rotates, consumer cutovers cannot safely proceed.' },
+ { id: 'manifests', title: 'Platform manifests', subtitle: 'being repaired', kind: 'artifact', x: 68, y: 46, note: 'Deploy specs for data-platform. Unfinished at session end — a gate on deployment.' },
+ { id: 'tooling', title: 'Recovery tooling', subtitle: 'being repaired', kind: 'tool', x: 70, y: 66, note: 'Scripts to restore physical PG18 backups. Proven in principle, not yet production-ready.' },
+ { id: 'dataplatform', title: 'data-platform', subtitle: 'NOT deployed', kind: 'artifact', x: 88, y: 40, note: 'The deployment target. It never went out — an interpreter that reports this session as "done" has failed here.' },
+ { id: 'cutovers', title: 'Restores & cutovers', subtitle: 'not started', kind: 'goal', x: 85, y: 78, note: 'The actual finish line: restore PG18/PG19/FalkorDB, then move consumers over. Not started.' },
+ ],
+ edges: [
+ { from: 'plan', to: 'pg18', label: 'scopes recovery', strong: true },
+ { from: 'plan', to: 'image', label: 'requires' },
+ { from: 'image', to: 'pg18', label: 'enables restore' },
+ { from: 'pg18', to: 'falkor', label: 'reconcile first' },
+ { from: 'plan', to: 'manifests', label: 'defines' },
+ { from: 'manifests', to: 'dataplatform', label: 'gates', strong: true },
+ { from: 'tooling', to: 'cutovers', label: 'unblocks' },
+ { from: 'dataplatform', to: 'cutovers', label: 'precedes' },
+ { from: 'token', to: 'cutovers', label: 'blocks until rotated', strong: true },
+ ],
+ },
+};
+
+const walkthrough: ExhibitArtifact = {
+ id: 'plan-walkthrough',
+ exhibitType: 'walkthrough',
+ title: 'The plan-authoring turn',
+ narrative:
+ 'One turn deserves the deep dive: the one where the session stopped taking orders and ' +
+ 'wrote its own contract. It did not just list tasks — it fixed a definition of "complete" ' +
+ 'and an ordering constraint that every later decision obeyed. That is why this turn, not ' +
+ 'the code, is the spine of the whole session.',
+ relevance: 0.7,
+ decayClass: 'slow',
+ createdAtEvent: 15,
+ status: 'active',
+ payload: {
+ headline: 'The turn where the session wrote its own contract.',
+ body:
+ 'Asked for "a plan to stand up the databases," the turn produced a database-only ' +
+ 'execution plan with two teeth: a hard completion definition and a strict ordering ' +
+ 'gate. Everything downstream — recovery order, the compat gate, the deferred token — ' +
+ 'is that contract being honored.',
+ tags: [
+ { label: 'match · database-only scope', kind: 'match' },
+ { label: 'addition · explicit completion definition', kind: 'addition' },
+ { label: 'addition · PG18→FalkorDB ordering gate', kind: 'addition' },
+ { label: 'risk · token rotation deferred', kind: 'risk' },
+ ],
+ satellites: [
+ { id: 'research', label: 'web research', note: 'prior lessons pulled forward', x: 22, y: 24 },
+ { id: 'survey', label: 'repo survey', note: 'existing homelab state read', x: 78, y: 26 },
+ { id: 'define', label: 'completion def', note: 'healthy · restored · roles · reads/writes · backup proved', x: 23, y: 73 },
+ { id: 'order', label: 'ordering gate', note: 'PG18 + FalkorDB first; PG19 via compat only', x: 77, y: 73 },
+ ],
+ files: [
+ { label: 'docs/plans/data-platform-databases.md', x: 51, y: 15 },
+ { label: 'PG18 recovery scope (8 dbs)', x: 66, y: 88 },
+ ],
+ },
+};
+
+const concernSnapshot: ExhibitArtifact = {
+ id: 'concern-map',
+ exhibitType: 'concern_snapshot',
+ title: 'Where the heat is',
+ narrative:
+ 'Abstract the work into concerns, not directories, and the shape of the ending is ' +
+ 'obvious: the image build ran hottest and finished, tooling and manifests are still ' +
+ 'warm and half-repaired, and the thing that actually ships value — restores and ' +
+ 'cutovers — is stone cold and not started. Heat here is effort spent, not progress made.',
+ relevance: 0.85,
+ decayClass: 'medium',
+ createdAtEvent: 28,
+ status: 'active',
+ payload: {
+ concerns: [
+ { id: 'image', title: 'PG18 image build', role: 'custom Postgres 18 image — the unblock', x: 8, y: 16, w: 25, h: 24, heat: 'very-high' },
+ { id: 'tooling', title: 'Recovery tooling', role: 'scripts to restore physical PG18 backups', x: 37, y: 14, w: 25, h: 22, heat: 'high' },
+ { id: 'manifests', title: 'Platform manifests', role: 'deploy specs for data-platform', x: 67, y: 16, w: 25, h: 22, heat: 'high' },
+ { id: 'token', title: 'Token rotation', role: 'exposed FORGEJO token, deferred to Doppler', x: 10, y: 48, w: 25, h: 20, heat: 'high' },
+ { id: 'falkor', title: 'FalkorDB compat', role: 'compatibility gate — proven', x: 39, y: 50, w: 23, h: 18, heat: 'medium' },
+ { id: 'cutovers', title: 'Restores & cutovers', role: 'not started', x: 66, y: 48, w: 22, h: 18, heat: 'low' },
+ ],
+ flows: [
+ ['image', 'tooling'],
+ ['image', 'falkor'],
+ ['tooling', 'cutovers'],
+ ['manifests', 'cutovers'],
+ ['token', 'cutovers'],
+ ],
+ },
+};
+
+const seismograph: ExhibitArtifact = {
+ id: 'session-seismograph',
+ exhibitType: 'seismograph',
+ title: 'The session as a seismic trace',
+ narrative:
+ 'Plotted as tremors, the session tells the truth its status update softens: two sharp ' +
+ 'downward spikes — the GHCR-403 stall and an aborted turn — bracket the exposed-token ' +
+ 'dip, and the only strong upward tremor is "recovery proven," which is a proof, not a ' +
+ 'shipment. A calm final reading is not the same as a finished one.',
+ relevance: 0.8,
+ decayClass: 'medium',
+ createdAtEvent: 30,
+ status: 'active',
+ payload: {
+ windowMinutes: 190,
+ trace: [
+ { at: '08:39', magnitude: 0.5, kind: 'turn', label: 'plan authored' },
+ { at: '09:04', magnitude: 0.65, kind: 'turn', label: 'implement' },
+ { at: '09:40', magnitude: 0.3, kind: 'tool' },
+ { at: '10:12', magnitude: -0.9, kind: 'failure', label: 'GHCR 403 stall' },
+ { at: '10:35', magnitude: 0.35, kind: 'tool' },
+ { at: '10:48', magnitude: -0.7, kind: 'incident', label: 'token exposed' },
+ { at: '11:05', magnitude: -0.82, kind: 'abort', label: 'turn aborted' },
+ { at: '11:20', magnitude: 0.6, kind: 'merge', label: 'recovery proven' },
+ { at: '11:48', magnitude: 0.12, kind: 'quiet', label: 'ends mid-path' },
+ ],
+ annotations: [
+ { at: '10:12', text: 'credential wall, not a code bug' },
+ { at: '11:05', text: 'turn abort — the one in the rollout' },
+ ],
+ },
+};
+
+const momentum: ExhibitArtifact = {
+ id: 'session-momentum',
+ exhibitType: 'momentum',
+ title: 'Momentum: parked mid-path',
+ narrative:
+ 'The honest read: real, proven progress on the hardest primitives, and zero delivered ' +
+ 'outcomes. The needle sits below halfway on purpose — the image is done and recovery is ' +
+ 'proven, but deployment, restores, and cutovers have not started. Anyone reporting ' +
+ '"databases done" is reading effort as completion.',
+ relevance: 0.9,
+ decayClass: 'medium',
+ createdAtEvent: 34,
+ status: 'active',
+ payload: {
+ value: 44,
+ label: 'Parked mid-path',
+ stats: [
+ { label: 'PG18 image', value: 'complete', tone: 'positive' },
+ { label: 'Recovery', value: 'proven', tone: 'positive' },
+ { label: 'Manifests', value: 'repairing', tone: 'caution' },
+ { label: 'Cutovers', value: 'not started', tone: 'negative' },
+ ],
+ next: [
+ { title: 'Rotate FORGEJO_INTERNAL_TOKEN via Doppler', evidence: 'exposed in-session; rotation deliberately deferred, not skipped', confidence: 88 },
+ { title: 'Finish & merge manifests + recovery tooling', evidence: 'both "being repaired" at the final status', confidence: 72 },
+ { title: 'Restore PG18 → PG19 → FalkorDB, then cut consumers over', evidence: 'stated critical path; none of it started', confidence: 64 },
+ ],
+ curation: [
+ { artifactId: 'early-db-guess', action: 'retire', reason: 'config_fleet / cloud_inventory disproved in-session; superseded by the confirmed eight-database scope' },
+ { artifactId: 'session-seismograph', action: 'refresh', reason: 'the GHCR stall resolves once the token rotates — refresh the trace when credentials land' },
+ ],
+ },
+};
+
+const liveGraph: ExhibitArtifact = {
+ id: 'live-graph',
+ exhibitType: 'live_graph',
+ title: 'Live agent graph',
+ narrative:
+ 'The force-directed topology of the observed agent and its tool activity. It is the one ' +
+ 'exhibit that moves with the session in real time — kept on the floor as the live pulse ' +
+ 'beneath the curated, interpreted exhibits around it.',
+ relevance: 0.5,
+ decayClass: 'fast',
+ createdAtEvent: 1,
+ status: 'active',
+ payload: {},
+};
+
+const retiredEarlyGuess: ExhibitArtifact = {
+ id: 'early-db-guess',
+ exhibitType: 'relationship_dag',
+ title: 'Early database guess',
+ narrative:
+ 'An early reading that the recovery surface included config_fleet and cloud_inventory. ' +
+ 'It taught the session what to disprove, then stopped being true. Retired, not deleted — ' +
+ 'the archive remembers the wrong turns too.',
+ relevance: 0.2,
+ decayClass: 'fast',
+ createdAtEvent: 9,
+ status: 'retired',
+ retirementReason: 'config_fleet / cloud_inventory disproved in-session; replaced by the confirmed eight-database PG18 scope.',
+ payload: {
+ focusNodeId: 'guess',
+ nodes: [
+ { id: 'guess', title: 'Guessed DB set', subtitle: 'incl. config_fleet, cloud_inventory', kind: 'file', x: 30, y: 40, note: 'Superseded by the confirmed eight-database scope.' },
+ { id: 'config_fleet', title: 'config_fleet', subtitle: 'disproved', kind: 'file', x: 66, y: 26, note: 'Not part of the PG18 recovery surface.' },
+ { id: 'cloud_inventory', title: 'cloud_inventory', subtitle: 'disproved', kind: 'file', x: 66, y: 60, note: 'Not part of the PG18 recovery surface.' },
+ ],
+ edges: [
+ { from: 'guess', to: 'config_fleet', label: 'assumed' },
+ { from: 'guess', to: 'cloud_inventory', label: 'assumed' },
+ ],
+ },
+};
+
+/**
+ * The standing fixture gallery, in authored order. The stage sorts the active
+ * set by relevance for rotation; retired artifacts fall to the archive shelf.
+ */
+export const fixtureGallery: ExhibitArtifact[] = [
+ activityNarrative,
+ relationshipDag,
+ walkthrough,
+ concernSnapshot,
+ seismograph,
+ momentum,
+ liveGraph,
+ retiredEarlyGuess,
+];
+
+export default fixtureGallery;
diff --git a/src/renderer/exhibits/primitives.tsx b/src/renderer/exhibits/primitives.tsx
new file mode 100644
index 0000000..dcb0e09
--- /dev/null
+++ b/src/renderer/exhibits/primitives.tsx
@@ -0,0 +1,161 @@
+/**
+ * Ported prototype primitives (`docs/ideas/repoviz/exhibit-prototype.jsx`),
+ * re-expressed in TypeScript against plain CSS classes (this repo has no
+ * Tailwind). These are the shared spatial language every exhibit is built from:
+ * the dark {@link Frame}, {@link accentByKind} entity colors, the {@link Chip}
+ * eyebrow, the absolutely-positioned {@link ExhibitNode}, and the SVG path
+ * helpers {@link curvedPath} / {@link arcPath}.
+ */
+import type { CSSProperties, ReactNode } from 'react';
+import { motion, useReducedMotion } from 'framer-motion';
+import type { NodeKind } from './types';
+
+export function cx(...parts: Array): string {
+ return parts.filter(Boolean).join(' ');
+}
+
+export interface Point {
+ x: number;
+ y: number;
+}
+
+export interface AccentPair {
+ from: string;
+ to: string;
+}
+
+/**
+ * kind -> gradient color pair. Extends the prototype's repo-entity map
+ * (pr/issue/branch/release/commit/file/module) with the plan's agent-session
+ * node kinds (goal/turn/tool/file/incident/artifact).
+ */
+export const accentByKind: Record = {
+ // Agent-session node kinds (plan vocabulary)
+ goal: { from: 'rgba(52,211,153,0.75)', to: 'rgba(20,184,166,0.75)' },
+ turn: { from: 'rgba(56,189,248,0.75)', to: 'rgba(14,165,233,0.75)' },
+ tool: { from: 'rgba(167,139,250,0.75)', to: 'rgba(99,102,241,0.75)' },
+ file: { from: 'rgba(244,114,182,0.75)', to: 'rgba(236,72,153,0.75)' },
+ incident: { from: 'rgba(251,146,60,0.8)', to: 'rgba(248,113,113,0.85)' },
+ artifact: { from: 'rgba(129,140,248,0.75)', to: 'rgba(139,92,246,0.75)' },
+ // Prototype repo-entity kinds (kept for continuity)
+ pr: { from: 'rgba(56,189,248,0.7)', to: 'rgba(14,165,233,0.7)' },
+ issue: { from: 'rgba(251,191,36,0.7)', to: 'rgba(249,115,22,0.7)' },
+ branch: { from: 'rgba(232,121,249,0.7)', to: 'rgba(168,85,247,0.7)' },
+ release: { from: 'rgba(52,211,153,0.7)', to: 'rgba(20,184,166,0.7)' },
+ commit: { from: 'rgba(167,139,250,0.7)', to: 'rgba(99,102,241,0.7)' },
+ module: { from: 'rgba(96,165,250,0.7)', to: 'rgba(34,211,238,0.7)' },
+};
+
+export function accentFor(kind: string): AccentPair {
+ return accentByKind[kind] ?? accentByKind.turn;
+}
+
+export function accentGradient(kind: string): string {
+ const pair = accentFor(kind);
+ return `linear-gradient(135deg, ${pair.from}, ${pair.to})`;
+}
+
+/** Quadratic-curve SVG path between two points, bulged by `intensity`. */
+export function curvedPath(a: Point, b: Point, intensity = 9): string {
+ const mx = (a.x + b.x) / 2;
+ const my = (a.y + b.y) / 2;
+ const curve = a.y < b.y ? -intensity : intensity;
+ return `M ${a.x} ${a.y} Q ${mx} ${my + curve} ${b.x} ${b.y}`;
+}
+
+/** Arc SVG path on a circle, degrees measured clockwise from 12 o'clock. */
+export function arcPath(cx: number, cy: number, r: number, startDeg: number, endDeg: number): string {
+ const start = ((startDeg - 90) * Math.PI) / 180;
+ const end = ((endDeg - 90) * Math.PI) / 180;
+ const sx = cx + r * Math.cos(start);
+ const sy = cy + r * Math.sin(start);
+ const ex = cx + r * Math.cos(end);
+ const ey = cy + r * Math.sin(end);
+ const largeArc = endDeg - startDeg <= 180 ? 0 : 1;
+ return `M ${sx} ${sy} A ${r} ${r} 0 ${largeArc} 1 ${ex} ${ey}`;
+}
+
+/**
+ * The dark spatial container: #050914 surface, triple radial glows, faint 60px
+ * grid, and a top/bottom vignette. Everything an exhibit draws lives inside it.
+ */
+export function Frame({
+ children,
+ className,
+ style,
+}: {
+ children: ReactNode;
+ className?: string;
+ style?: CSSProperties;
+}) {
+ return (
+
+ );
+}
+
+/** Uppercase, letter-spaced eyebrow pill. */
+export function Chip({ children, className }: { children: ReactNode; className?: string }) {
+ return {children};
+}
+
+export interface ExhibitNodeProps {
+ x: number;
+ y: number;
+ title: string;
+ subtitle: string;
+ kind: string;
+ urgent?: boolean;
+ selected?: boolean;
+ onHover?: () => void;
+}
+
+/**
+ * A node card positioned by percentage coordinates: gradient kind-dot,
+ * title/subtitle, an optional pulsing `urgent` badge, and a selected ring.
+ */
+export function ExhibitNode({ x, y, title, subtitle, kind, urgent, selected, onHover }: ExhibitNodeProps) {
+ const reduce = useReducedMotion();
+ return (
+
+
+
+
+ {title}
+ {urgent ? (
+
+ urgent
+
+ ) : null}
+
+ {subtitle}
+
+
+ );
+}
+
+/** Shared entry/exit variant for the AnimatePresence stage swap. */
+export const stageSwapVariants = {
+ initial: { opacity: 0, y: 12, scale: 0.992 },
+ animate: { opacity: 1, y: 0, scale: 1 },
+ exit: { opacity: 0, y: -10, scale: 0.992 },
+};
+
+export type { NodeKind };
diff --git a/src/renderer/exhibits/types.ts b/src/renderer/exhibits/types.ts
new file mode 100644
index 0000000..3d6bd87
--- /dev/null
+++ b/src/renderer/exhibits/types.ts
@@ -0,0 +1,262 @@
+/**
+ * Exhibit vocabulary — the typed artifact contracts the shadow curator fills.
+ *
+ * This is the v1 vocabulary from `docs/plans/plan-exhibit-floor.md`: seven
+ * authored exhibit types plus `live_graph`, which wraps the existing Canvas2D
+ * force graph so it can ride in the same rotation. Every exhibit the model
+ * authors is wrapped in the {@link ExhibitArtifact} envelope (id, narrative,
+ * relevance, decay, status) and carries a payload typed per `exhibitType` as a
+ * discriminated union.
+ */
+
+/** Entity kinds used by nodes across DAG / walkthrough exhibits. */
+export type NodeKind = 'goal' | 'turn' | 'tool' | 'file' | 'incident' | 'artifact';
+
+/** Heat buckets shared by concern snapshots (and node urgency shading). */
+export type HeatLevel = 'low' | 'medium' | 'high' | 'very-high';
+
+// --- Payload contracts (one per exhibit type) ---------------------------------
+
+export interface DagNode {
+ id: string;
+ title: string;
+ subtitle: string;
+ kind: NodeKind;
+ /** Percentage coords within the frame (0..100). */
+ x: number;
+ y: number;
+ /** Curator note surfaced in the commentary panel when this node is focused. */
+ note: string;
+ urgent?: boolean;
+}
+
+export interface DagEdge {
+ from: string;
+ to: string;
+ label: string;
+ strong?: boolean;
+}
+
+export interface RelationshipDagPayload {
+ nodes: DagNode[];
+ edges: DagEdge[];
+ focusNodeId: string;
+}
+
+export interface NarrativeBeat {
+ at: string;
+ title: string;
+ body: string;
+ side: 'top' | 'bottom';
+ thread: string;
+}
+
+export interface NarrativeThread {
+ id: string;
+ label: string;
+ color?: string;
+}
+
+export interface ActivityNarrativePayload {
+ beats: NarrativeBeat[];
+ threads: NarrativeThread[];
+}
+
+export interface WalkthroughTag {
+ label: string;
+ kind: 'match' | 'addition' | 'risk';
+}
+
+export interface WalkthroughSatellite {
+ id: string;
+ label: string;
+ note: string;
+ x: number;
+ y: number;
+}
+
+export interface WalkthroughFile {
+ label: string;
+ x: number;
+ y: number;
+}
+
+export interface WalkthroughPayload {
+ headline: string;
+ body: string;
+ tags: WalkthroughTag[];
+ satellites: WalkthroughSatellite[];
+ files: WalkthroughFile[];
+}
+
+export interface Concern {
+ id: string;
+ title: string;
+ role: string;
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ heat: HeatLevel;
+}
+
+export interface ConcernSnapshotPayload {
+ concerns: Concern[];
+ /** Directed flows between concern ids: [fromId, toId]. */
+ flows: Array<[string, string]>;
+}
+
+export interface MomentumStat {
+ label: string;
+ value: string;
+ tone: 'positive' | 'neutral' | 'caution' | 'negative';
+}
+
+export interface MomentumNextItem {
+ title: string;
+ evidence: string;
+ /** 0..100 confidence. */
+ confidence: number;
+}
+
+export interface CurationRecommendation {
+ artifactId: string;
+ action: 'refresh' | 'retire';
+ reason: string;
+}
+
+export interface MomentumPayload {
+ /** 0..100 gauge value. */
+ value: number;
+ label: string;
+ stats: MomentumStat[];
+ next: MomentumNextItem[];
+ curation: CurationRecommendation[];
+}
+
+export interface SeismographTremor {
+ at: string;
+ /** -1..1 signed deflection magnitude. */
+ magnitude: number;
+ kind: NodeKind | 'merge' | 'abort' | 'failure' | 'quiet';
+ label?: string;
+}
+
+export interface SeismographAnnotation {
+ at: string;
+ text: string;
+}
+
+export interface SeismographPayload {
+ trace: SeismographTremor[];
+ annotations: SeismographAnnotation[];
+ windowMinutes: number;
+}
+
+export interface ThermalCell {
+ path: string;
+ /** Relative area weight for the treemap-ish layout. */
+ weight: number;
+ /** 0..1 heat, cool blue -> hot red. */
+ heat: number;
+ label?: string;
+}
+
+export interface ThermalMapPayload {
+ cells: ThermalCell[];
+ hottest: { path: string; why: string };
+}
+
+/** `live_graph` wraps the existing canvas; it carries no authored payload. */
+export type LiveGraphPayload = Record;
+
+// --- Envelope + discriminated union ------------------------------------------
+
+/** Maps every exhibit type to its payload contract. */
+export interface ExhibitPayloadMap {
+ relationship_dag: RelationshipDagPayload;
+ activity_narrative: ActivityNarrativePayload;
+ walkthrough: WalkthroughPayload;
+ concern_snapshot: ConcernSnapshotPayload;
+ momentum: MomentumPayload;
+ seismograph: SeismographPayload;
+ thermal_map: ThermalMapPayload;
+ live_graph: LiveGraphPayload;
+}
+
+export type ExhibitType = keyof ExhibitPayloadMap;
+
+export type DecayClass = 'fast' | 'medium' | 'slow';
+export type ExhibitStatus = 'fresh' | 'active' | 'stale' | 'retired';
+
+/** Fields every exhibit envelope shares, independent of payload. */
+export interface ExhibitArtifactBase {
+ /** Model-assigned, stable across refreshes. */
+ id: string;
+ title: string;
+ /**
+ * MANDATORY. What it means and why it's on the floor — not what happened.
+ * Raw data is noise; the narrative is the exhibit's reason to exist.
+ */
+ narrative: string;
+ /** 0..1, drives rotation order. */
+ relevance: number;
+ decayClass: DecayClass;
+ /** Event index when authored. */
+ createdAtEvent: number;
+ refreshedAtEvent?: number;
+ status: ExhibitStatus;
+ /** Why an artifact was retired — rendered on the archive shelf. */
+ retirementReason?: string;
+}
+
+/** The discriminated union: envelope + payload keyed by `exhibitType`. */
+export type ExhibitArtifact = {
+ [K in ExhibitType]: ExhibitArtifactBase & {
+ exhibitType: K;
+ payload: ExhibitPayloadMap[K];
+ };
+}[ExhibitType];
+
+/** Narrow an artifact to a specific exhibit type. */
+export type ExhibitArtifactOf = Extract;
+
+// --- Type guards --------------------------------------------------------------
+
+export function isExhibitOfType(
+ artifact: ExhibitArtifact,
+ type: K
+): artifact is ExhibitArtifactOf {
+ return artifact.exhibitType === type;
+}
+
+export const isRelationshipDag = (a: ExhibitArtifact): a is ExhibitArtifactOf<'relationship_dag'> =>
+ a.exhibitType === 'relationship_dag';
+export const isActivityNarrative = (a: ExhibitArtifact): a is ExhibitArtifactOf<'activity_narrative'> =>
+ a.exhibitType === 'activity_narrative';
+export const isWalkthrough = (a: ExhibitArtifact): a is ExhibitArtifactOf<'walkthrough'> =>
+ a.exhibitType === 'walkthrough';
+export const isConcernSnapshot = (a: ExhibitArtifact): a is ExhibitArtifactOf<'concern_snapshot'> =>
+ a.exhibitType === 'concern_snapshot';
+export const isMomentum = (a: ExhibitArtifact): a is ExhibitArtifactOf<'momentum'> =>
+ a.exhibitType === 'momentum';
+export const isSeismograph = (a: ExhibitArtifact): a is ExhibitArtifactOf<'seismograph'> =>
+ a.exhibitType === 'seismograph';
+export const isThermalMap = (a: ExhibitArtifact): a is ExhibitArtifactOf<'thermal_map'> =>
+ a.exhibitType === 'thermal_map';
+export const isLiveGraph = (a: ExhibitArtifact): a is ExhibitArtifactOf<'live_graph'> =>
+ a.exhibitType === 'live_graph';
+
+/** True when the artifact has been retired to the archive shelf. */
+export const isRetired = (a: ExhibitArtifact): boolean => a.status === 'retired';
+
+/** The set of all authored (non-live) exhibit types, in vocabulary order. */
+export const AUTHORED_EXHIBIT_TYPES: ExhibitType[] = [
+ 'relationship_dag',
+ 'activity_narrative',
+ 'walkthrough',
+ 'concern_snapshot',
+ 'momentum',
+ 'seismograph',
+ 'thermal_map',
+];
diff --git a/src/renderer/renderer-surface-adapter.tsx b/src/renderer/renderer-surface-adapter.tsx
index cea10cc..fb089cd 100644
--- a/src/renderer/renderer-surface-adapter.tsx
+++ b/src/renderer/renderer-surface-adapter.tsx
@@ -2,19 +2,22 @@ import type { ComponentType } from 'react';
import CanvasRenderer, { type CanvasRendererProps } from './canvas/CanvasRenderer';
import ShadowPanel, { type ShadowPanelProps } from './components/ShadowPanel';
import TimelineScrubber, { type TimelineScrubberProps } from './components/TimelineScrubber';
+import ExhibitStage, { type ExhibitStageProps } from './exhibits/ExhibitStage';
export interface RendererSurfaceAdapter {
readonly id: string;
readonly GraphCanvas: ComponentType;
readonly Timeline: ComponentType;
readonly ShadowPanel: ComponentType;
+ readonly ExhibitStage: ComponentType;
}
const currentRendererSurfaceAdapter: RendererSurfaceAdapter = {
id: 'default-renderer-surfaces',
GraphCanvas: CanvasRenderer,
Timeline: TimelineScrubber,
- ShadowPanel
+ ShadowPanel,
+ ExhibitStage
};
export function getRendererSurfaceAdapter(): RendererSurfaceAdapter {
diff --git a/src/renderer/styles.css b/src/renderer/styles.css
index b3a498b..76924e7 100644
--- a/src/renderer/styles.css
+++ b/src/renderer/styles.css
@@ -908,3 +908,725 @@ button {
.panels--3col > .panel--wide {
grid-column: 1 / -1;
}
+
+/* =========================================================================
+ Exhibit floor — the ported RepoVis visual language (see
+ docs/plans/plan-exhibit-floor.md and docs/ideas/repoviz/exhibit-prototype.jsx).
+ Dark spatial Frame, curated exhibits, idle-cycling stage, agent commentary.
+ ========================================================================= */
+:root {
+ --exhibit-surface: #050914;
+ --exhibit-glass: rgba(0, 0, 0, 0.45);
+ --exhibit-glass-strong: rgba(0, 0, 0, 0.55);
+ --exhibit-muted: rgba(255, 255, 255, 0.64);
+ --exhibit-faint: rgba(255, 255, 255, 0.44);
+ --exhibit-cyan: rgba(125, 224, 255, 0.85);
+ --exhibit-radius: 30px;
+ --exhibit-radius-inner: 22px;
+ /* Concern / heat ramp: slate -> cyan -> fuchsia -> rose/orange */
+ --heat-low-a: rgba(148, 163, 184, 0.16);
+ --heat-low-b: rgba(100, 116, 139, 0.1);
+ --heat-low-border: rgba(255, 255, 255, 0.12);
+ --heat-medium-a: rgba(56, 189, 248, 0.22);
+ --heat-medium-b: rgba(14, 165, 233, 0.12);
+ --heat-medium-border: rgba(103, 232, 249, 0.28);
+ --heat-high-a: rgba(232, 121, 249, 0.24);
+ --heat-high-b: rgba(168, 85, 247, 0.14);
+ --heat-high-border: rgba(232, 121, 249, 0.3);
+ --heat-very-high-a: rgba(251, 113, 133, 0.28);
+ --heat-very-high-b: rgba(251, 146, 60, 0.2);
+ --heat-very-high-border: rgba(251, 146, 60, 0.38);
+}
+
+.exhibit-surface { margin-top: 4px; }
+
+/* --- Stage: rail + main -------------------------------------------------- */
+.exhibit-stage {
+ display: grid;
+ grid-template-columns: 320px minmax(0, 1fr);
+ gap: 20px;
+ align-items: start;
+}
+
+@media (max-width: 1080px) {
+ .exhibit-stage { grid-template-columns: 1fr; }
+}
+
+.exhibit-rail {
+ border-radius: var(--exhibit-radius-inner);
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.03);
+ padding: 14px;
+ backdrop-filter: blur(12px);
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.exhibit-rail__eyebrow,
+.exhibit-archive__eyebrow {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.2em;
+ color: var(--exhibit-faint);
+ padding: 4px 6px 0;
+}
+
+.exhibit-rail__list { display: flex; flex-direction: column; gap: 8px; }
+
+.exhibit-rail__item {
+ display: flex;
+ gap: 12px;
+ text-align: left;
+ border-radius: var(--exhibit-radius-inner);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ background: rgba(255, 255, 255, 0.02);
+ padding: 12px 14px;
+ color: #fff;
+ cursor: pointer;
+ transition: background 200ms ease, border-color 200ms ease;
+}
+
+.exhibit-rail__item:hover { background: rgba(255, 255, 255, 0.05); }
+
+.exhibit-rail__item--active {
+ border-color: rgba(103, 232, 249, 0.28);
+ background: rgba(34, 211, 238, 0.1);
+ box-shadow: 0 0 30px rgba(34, 211, 238, 0.1);
+}
+
+.exhibit-rail__icon {
+ flex-shrink: 0;
+ display: grid;
+ place-items: center;
+ width: 34px;
+ height: 34px;
+ border-radius: 12px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(255, 255, 255, 0.05);
+ color: var(--exhibit-cyan);
+}
+
+.exhibit-rail__main { min-width: 0; display: flex; flex-direction: column; gap: 5px; }
+.exhibit-rail__topline { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
+
+.exhibit-rail__title {
+ font-size: 14px;
+ font-weight: 600;
+ color: #fff;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.exhibit-rail__type { font-size: 11px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--exhibit-faint); }
+
+.exhibit-status {
+ flex-shrink: 0;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.14em;
+ padding: 2px 8px;
+ border-radius: 999px;
+ border: 1px solid rgba(255, 255, 255, 0.14);
+ color: var(--exhibit-muted);
+}
+
+.exhibit-status--fresh { color: #86efac; border-color: rgba(134, 239, 172, 0.32); background: rgba(52, 211, 153, 0.12); }
+.exhibit-status--active { color: #7dd3fc; border-color: rgba(125, 211, 252, 0.32); background: rgba(56, 189, 248, 0.12); }
+.exhibit-status--stale { color: #fcd34d; border-color: rgba(252, 211, 77, 0.3); background: rgba(251, 191, 36, 0.1); }
+.exhibit-status--retired { color: rgba(255, 255, 255, 0.5); }
+
+.exhibit-rail__relevance {
+ display: block;
+ height: 4px;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.08);
+ overflow: hidden;
+}
+
+.exhibit-rail__relevance > span {
+ display: block;
+ height: 100%;
+ border-radius: 999px;
+ background: linear-gradient(90deg, rgba(125, 224, 255, 0.9), rgba(232, 121, 249, 0.9));
+}
+
+.exhibit-rail__preview {
+ font-size: 12px;
+ line-height: 1.5;
+ color: var(--exhibit-muted);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+/* --- Archive shelf ------------------------------------------------------- */
+.exhibit-archive {
+ margin-top: 6px;
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
+ padding-top: 10px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.exhibit-archive__strip { display: flex; flex-wrap: wrap; gap: 6px; }
+
+.exhibit-archive__item {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.55);
+ padding: 5px 10px;
+ border-radius: 999px;
+ border: 1px dashed rgba(255, 255, 255, 0.16);
+ background: rgba(255, 255, 255, 0.02);
+ cursor: help;
+}
+
+.exhibit-archive__item svg { opacity: 0.7; }
+
+/* --- Main column -------------------------------------------------------- */
+.exhibit-main { display: flex; flex-direction: column; gap: 14px; }
+
+.exhibit-main__bar {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 16px;
+ border-radius: var(--exhibit-radius-inner);
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.03);
+ padding: 14px 18px;
+ backdrop-filter: blur(12px);
+}
+
+.exhibit-main__eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: 0.22em; color: var(--exhibit-cyan); }
+.exhibit-main__title { margin-top: 4px; font-size: 20px; font-weight: 600; color: #fff; }
+.exhibit-main__controls { display: flex; align-items: center; gap: 8px; }
+
+.exhibit-pill {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.16em;
+ padding: 6px 12px;
+ border-radius: 999px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ color: var(--exhibit-muted);
+}
+
+.exhibit-pill--active { color: #7dd3fc; border-color: rgba(125, 211, 252, 0.3); background: rgba(56, 189, 248, 0.1); }
+.exhibit-pill--paused { color: #fcd34d; border-color: rgba(252, 211, 77, 0.3); background: rgba(251, 191, 36, 0.1); }
+
+.exhibit-ctl {
+ width: 34px;
+ height: 34px;
+ border-radius: 12px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: rgba(255, 255, 255, 0.04);
+ color: #fff;
+ font-size: 18px;
+ line-height: 1;
+ cursor: pointer;
+ transition: background 160ms ease;
+}
+
+.exhibit-ctl:hover { background: rgba(255, 255, 255, 0.1); }
+
+.exhibit-main__frame { position: relative; }
+
+/* --- Frame primitive ---------------------------------------------------- */
+.exhibit-frame {
+ position: relative;
+ height: clamp(560px, 64vh, 760px);
+ overflow: hidden;
+ border-radius: var(--exhibit-radius);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: var(--exhibit-surface);
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.02), 0 30px 80px rgba(0, 0, 0, 0.65);
+}
+
+.exhibit-frame__glows {
+ position: absolute;
+ inset: 0;
+ background:
+ radial-gradient(circle at top left, rgba(56, 189, 248, 0.14), transparent 32%),
+ radial-gradient(circle at 75% 10%, rgba(192, 132, 252, 0.14), transparent 28%),
+ radial-gradient(circle at 50% 100%, rgba(52, 211, 153, 0.1), transparent 24%);
+ pointer-events: none;
+}
+
+.exhibit-frame__grid {
+ position: absolute;
+ inset: 0;
+ opacity: 0.08;
+ background-image:
+ linear-gradient(rgba(255, 255, 255, 0.9) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(255, 255, 255, 0.9) 1px, transparent 1px);
+ background-size: 60px 60px;
+ pointer-events: none;
+}
+
+.exhibit-frame__vignette {
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(to bottom, rgba(255, 255, 255, 0.02), transparent 12%, transparent 88%, rgba(255, 255, 255, 0.02));
+ pointer-events: none;
+}
+
+.exhibit-frame__chips {
+ position: absolute;
+ left: 28px;
+ top: 28px;
+ z-index: 10;
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.exhibit-svg { position: absolute; inset: 0; width: 100%; height: 100%; }
+
+.exhibit-chip {
+ border-radius: 999px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(255, 255, 255, 0.05);
+ padding: 5px 12px;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.22em;
+ color: var(--exhibit-muted);
+ white-space: nowrap;
+}
+
+/* --- Node primitive ----------------------------------------------------- */
+.exhibit-node {
+ position: absolute;
+ transform: translate(-50%, -50%);
+ display: flex;
+ gap: 12px;
+ align-items: flex-start;
+ text-align: left;
+ max-width: 240px;
+ border-radius: 18px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(0, 0, 0, 0.5);
+ padding: 12px 16px;
+ color: #fff;
+ cursor: pointer;
+ backdrop-filter: blur(6px);
+ transition: border-color 200ms ease, box-shadow 200ms ease;
+}
+
+.exhibit-node:hover { border-color: rgba(255, 255, 255, 0.2); }
+
+.exhibit-node--selected {
+ border-color: rgba(255, 255, 255, 0.3);
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08), 0 0 30px rgba(56, 189, 248, 0.14);
+}
+
+.exhibit-node__dot { margin-top: 3px; width: 14px; height: 14px; border-radius: 999px; flex-shrink: 0; }
+.exhibit-node__body { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
+.exhibit-node__topline { display: flex; align-items: center; gap: 8px; }
+.exhibit-node__title { font-size: 14px; font-weight: 600; color: #fff; }
+
+.exhibit-node__urgent {
+ border-radius: 999px;
+ border: 1px solid rgba(248, 113, 113, 0.25);
+ background: rgba(248, 113, 113, 0.1);
+ padding: 1px 8px;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.2em;
+ color: #fecaca;
+}
+
+.exhibit-node__subtitle { font-size: 12px; color: var(--exhibit-muted); }
+
+/* --- Focus / commentary cards ------------------------------------------- */
+.exhibit-focus-card {
+ position: absolute;
+ right: 28px;
+ bottom: 28px;
+ z-index: 12;
+ width: 340px;
+ max-width: calc(100% - 56px);
+ border-radius: 24px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: var(--exhibit-glass);
+ padding: 20px;
+ backdrop-filter: blur(14px);
+}
+
+.exhibit-focus-card__eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: 0.22em; color: var(--exhibit-cyan); }
+.exhibit-focus-card__title { margin-top: 12px; font-size: 20px; font-weight: 600; color: #fff; }
+.exhibit-focus-card__note { margin-top: 8px; font-size: 14px; line-height: 1.5; color: var(--exhibit-muted); }
+.exhibit-focus-card__stats { margin-top: 16px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
+
+.exhibit-stat-tile {
+ border-radius: 16px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ background: rgba(255, 255, 255, 0.03);
+ padding: 10px 12px;
+}
+
+.exhibit-stat-tile__label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.18em; color: var(--exhibit-faint); }
+.exhibit-stat-tile__value { margin-top: 4px; font-size: 14px; font-weight: 600; color: #fff; }
+.exhibit-stat-tile--positive .exhibit-stat-tile__value { color: #86efac; }
+.exhibit-stat-tile--caution .exhibit-stat-tile__value { color: #fcd34d; }
+.exhibit-stat-tile--negative .exhibit-stat-tile__value { color: #fda4af; }
+
+.exhibit-commentary {
+ position: absolute;
+ right: 28px;
+ bottom: 28px;
+ z-index: 20;
+ width: 360px;
+ max-width: calc(100% - 56px);
+ border-radius: 24px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: var(--exhibit-glass-strong);
+ padding: 20px;
+ backdrop-filter: blur(16px);
+}
+
+.exhibit-commentary__eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: 0.22em; color: var(--exhibit-cyan); }
+
+.exhibit-commentary__narrative {
+ margin-top: 12px;
+ font-size: 14px;
+ line-height: 1.6;
+ color: rgba(255, 255, 255, 0.72);
+ display: -webkit-box;
+ -webkit-line-clamp: 6;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.exhibit-commentary__tiles { margin-top: 16px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
+
+/* When both the exhibit's own focus card and the stage commentary exist,
+ tuck the focus card to the LEFT so it never sits under commentary. */
+.exhibit-main__frame .exhibit-focus-card { right: auto; left: 28px; }
+
+/* --- Live graph exhibit -------------------------------------------------- */
+.exhibit-live-graph { position: absolute; inset: 0; }
+.exhibit-live-graph canvas { border-radius: var(--exhibit-radius); }
+.exhibit-live-graph__fallback {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ color: var(--exhibit-muted);
+ font-size: 14px;
+}
+
+/* --- Activity Narrative ------------------------------------------------- */
+.activity-spine {
+ position: absolute;
+ left: 48px;
+ right: 48px;
+ top: 49%;
+ height: 1px;
+ background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.2), transparent);
+}
+
+.activity-dot {
+ position: absolute;
+ width: 16px;
+ height: 16px;
+ transform: translate(-50%, -50%);
+ border-radius: 999px;
+ border: 1px solid;
+ z-index: 6;
+}
+
+.activity-dot--idle { border-color: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.1); }
+.activity-dot--active { border-color: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.8); }
+.activity-dot--selected { border-color: #a5f3fc; background: #67e8f9; box-shadow: 0 0 30px rgba(103, 232, 249, 0.7); }
+
+.activity-card {
+ position: absolute;
+ width: 240px;
+ transform: translateX(-50%);
+ border-radius: 22px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: var(--exhibit-glass);
+ padding: 16px;
+ backdrop-filter: blur(14px);
+ z-index: 8;
+}
+
+.activity-card--selected { box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08), 0 0 26px rgba(34, 211, 238, 0.18); }
+.activity-card__day { font-size: 11px; text-transform: uppercase; letter-spacing: 0.2em; color: var(--exhibit-faint); }
+.activity-card__title { margin-top: 8px; font-size: 16px; font-weight: 600; color: #fff; }
+.activity-card__body { margin-top: 8px; font-size: 13px; line-height: 1.5; color: rgba(255, 255, 255, 0.65); }
+.activity-card__thread {
+ margin-top: 12px;
+ display: inline-flex;
+ border-radius: 999px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(255, 255, 255, 0.05);
+ padding: 4px 10px;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.16em;
+ color: rgba(255, 255, 255, 0.5);
+}
+
+.activity-scrub { position: absolute; left: 40px; right: 40px; bottom: 24px; z-index: 10; }
+.activity-scrub__labels {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 12px;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.2em;
+ color: var(--exhibit-faint);
+}
+.activity-scrub input[type='range'] { width: 100%; accent-color: #67e8f9; }
+
+/* --- Walkthrough -------------------------------------------------------- */
+.walkthrough-center {
+ position: absolute;
+ left: 50%;
+ top: 48%;
+ z-index: 10;
+ width: 340px;
+ max-width: calc(100% - 56px);
+ transform: translate(-50%, -50%);
+ border-radius: 28px;
+ border: 1px solid rgba(103, 232, 249, 0.2);
+ background: var(--exhibit-glass-strong);
+ padding: 20px;
+ box-shadow: 0 0 40px rgba(34, 211, 238, 0.12);
+ backdrop-filter: blur(16px);
+}
+
+.walkthrough-center__eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: 0.22em; color: var(--exhibit-cyan); }
+.walkthrough-center__headline { margin-top: 14px; font-size: 22px; font-weight: 600; line-height: 1.25; color: #fff; }
+.walkthrough-center__body { margin-top: 12px; font-size: 14px; line-height: 1.55; color: rgba(255, 255, 255, 0.68); }
+.walkthrough-center__tags { margin-top: 16px; display: flex; flex-wrap: wrap; gap: 8px; }
+
+.walkthrough-tag { border-radius: 999px; padding: 4px 12px; font-size: 12px; border: 1px solid rgba(255, 255, 255, 0.14); color: rgba(255, 255, 255, 0.8); }
+.walkthrough-tag--match { border-color: rgba(52, 211, 153, 0.2); background: rgba(52, 211, 153, 0.1); color: #a7f3d0; }
+.walkthrough-tag--addition { border-color: rgba(232, 121, 249, 0.2); background: rgba(232, 121, 249, 0.1); color: #f5d0fe; }
+.walkthrough-tag--risk { border-color: rgba(251, 146, 60, 0.24); background: rgba(251, 146, 60, 0.12); color: #fed7aa; }
+
+.walkthrough-satellite {
+ position: absolute;
+ transform: translate(-50%, -50%);
+ width: 176px;
+ border-radius: 16px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(255, 255, 255, 0.05);
+ padding: 12px 14px;
+ backdrop-filter: blur(6px);
+ z-index: 8;
+}
+.walkthrough-satellite__eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: 0.18em; color: var(--exhibit-faint); }
+.walkthrough-satellite__label { margin-top: 4px; font-size: 14px; font-weight: 600; color: #fff; }
+.walkthrough-satellite__note { margin-top: 4px; font-size: 12px; color: rgba(255, 255, 255, 0.58); }
+
+.walkthrough-file {
+ position: absolute;
+ transform: translate(-50%, -50%);
+ border-radius: 14px;
+ border: 1px solid rgba(244, 114, 182, 0.15);
+ background: rgba(244, 114, 182, 0.08);
+ padding: 8px 12px;
+ font-size: 12px;
+ color: rgba(251, 207, 232, 0.85);
+ backdrop-filter: blur(6px);
+ z-index: 8;
+ white-space: nowrap;
+}
+
+/* --- Concern Snapshot --------------------------------------------------- */
+.concern-box {
+ position: absolute;
+ border-radius: 22px;
+ border: 1px solid;
+ padding: 14px 16px;
+ text-align: left;
+ color: #fff;
+ cursor: pointer;
+ backdrop-filter: blur(12px);
+ transition: box-shadow 200ms ease;
+ z-index: 6;
+}
+
+.concern-box--low { background: linear-gradient(135deg, var(--heat-low-a), var(--heat-low-b)); border-color: var(--heat-low-border); }
+.concern-box--medium { background: linear-gradient(135deg, var(--heat-medium-a), var(--heat-medium-b)); border-color: var(--heat-medium-border); }
+.concern-box--high { background: linear-gradient(135deg, var(--heat-high-a), var(--heat-high-b)); border-color: var(--heat-high-border); }
+.concern-box--very-high { background: linear-gradient(135deg, var(--heat-very-high-a), var(--heat-very-high-b)); border-color: var(--heat-very-high-border); }
+.concern-box--selected { box-shadow: 0 0 30px rgba(34, 211, 238, 0.14); }
+
+.concern-box__eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: 0.18em; color: var(--exhibit-faint); }
+.concern-box__title { margin-top: 6px; font-size: 17px; font-weight: 600; color: #fff; }
+.concern-box__role { margin-top: 6px; font-size: 13px; line-height: 1.45; color: rgba(255, 255, 255, 0.66); }
+
+.concern-heat-meter { margin-top: 16px; height: 8px; border-radius: 999px; background: rgba(255, 255, 255, 0.08); overflow: hidden; }
+.concern-heat-meter__fill { display: block; height: 100%; border-radius: 999px; background: linear-gradient(90deg, #67e8f9, #f0abfc, #fdba74); }
+.concern-heat-meter__label { margin-top: 8px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.18em; color: var(--exhibit-faint); }
+
+/* --- Momentum ----------------------------------------------------------- */
+.momentum-grid {
+ position: absolute;
+ inset: 72px 28px 28px;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 20px;
+ z-index: 8;
+ overflow: auto;
+}
+@media (max-width: 900px) {
+ .momentum-grid { grid-template-columns: 1fr; }
+}
+
+.momentum-panel {
+ border-radius: 24px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: var(--exhibit-glass);
+ padding: 20px;
+ backdrop-filter: blur(14px);
+}
+
+.momentum-panel__head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
+.momentum-panel__eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: 0.22em; color: var(--exhibit-faint); }
+.momentum-panel__eyebrow--accent { color: var(--exhibit-cyan); }
+.momentum-panel__label { margin-top: 8px; font-size: 26px; font-weight: 600; color: #fff; }
+
+.momentum-gauge-row { margin-top: 16px; display: flex; align-items: center; gap: 20px; flex-wrap: wrap; }
+.momentum-gauge { flex-shrink: 0; }
+.momentum-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; flex: 1; min-width: 180px; }
+
+.momentum-next { margin-top: 16px; display: flex; flex-direction: column; gap: 12px; }
+.momentum-next__item { border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.03); padding: 14px; }
+.momentum-next__topline { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
+.momentum-next__title { font-size: 14px; font-weight: 600; color: #fff; }
+.momentum-next__evidence { margin-top: 4px; font-size: 12px; color: rgba(255, 255, 255, 0.62); }
+.momentum-next__pct {
+ flex-shrink: 0;
+ border-radius: 999px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(255, 255, 255, 0.05);
+ padding: 3px 10px;
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.7);
+}
+.momentum-next__bar { margin-top: 12px; height: 8px; border-radius: 999px; background: rgba(255, 255, 255, 0.08); overflow: hidden; }
+.momentum-next__bar > span { display: block; height: 100%; border-radius: 999px; background: linear-gradient(90deg, #67e8f9, #f0abfc); }
+
+.momentum-curation {
+ position: absolute;
+ left: 28px;
+ right: 28px;
+ bottom: 28px;
+ z-index: 12;
+ border-radius: 22px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: var(--exhibit-glass-strong);
+ padding: 16px 20px;
+ backdrop-filter: blur(16px);
+ display: none;
+}
+@media (min-height: 760px) {
+ .momentum-curation { display: block; }
+}
+.momentum-curation__cards { margin-top: 12px; display: flex; gap: 12px; flex-wrap: wrap; }
+.curation-card { flex: 1; min-width: 220px; border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.12); padding: 12px 14px; }
+.curation-card--refresh { border-color: rgba(52, 211, 153, 0.2); background: rgba(52, 211, 153, 0.08); }
+.curation-card--retire { border-color: rgba(251, 146, 60, 0.2); background: rgba(251, 146, 60, 0.08); }
+.curation-card__action { font-size: 11px; text-transform: uppercase; letter-spacing: 0.2em; color: rgba(255, 255, 255, 0.7); }
+.curation-card__target { margin-top: 6px; font-size: 15px; font-weight: 600; color: #fff; }
+.curation-card__reason { margin-top: 4px; font-size: 12px; line-height: 1.45; color: rgba(255, 255, 255, 0.62); }
+
+/* --- Seismograph -------------------------------------------------------- */
+.seismo-label {
+ position: absolute;
+ transform: translateX(-50%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ z-index: 8;
+ pointer-events: none;
+ max-width: 130px;
+ text-align: center;
+}
+.seismo-label__time { font-size: 11px; font-weight: 600; letter-spacing: 0.1em; color: rgba(255, 255, 255, 0.82); }
+.seismo-label__text { font-size: 11px; line-height: 1.3; color: var(--exhibit-muted); }
+
+.seismo-axis {
+ position: absolute;
+ left: 32px;
+ right: 32px;
+ bottom: 22px;
+ display: flex;
+ justify-content: space-between;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.2em;
+ color: var(--exhibit-faint);
+}
+
+/* --- Thermal Map -------------------------------------------------------- */
+.thermal-cell {
+ position: absolute;
+ border-radius: 12px;
+ border: 1px solid;
+ padding: 8px 10px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ z-index: 6;
+}
+.thermal-cell--hottest { border-color: rgba(255, 255, 255, 0.9) !important; box-shadow: 0 0 24px rgba(255, 255, 255, 0.25); z-index: 7; }
+.thermal-cell__path { font-size: 12px; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.thermal-cell__heat { align-self: flex-end; font-size: 13px; font-weight: 700; }
+
+.thermal-callout {
+ position: absolute;
+ left: 28px;
+ right: 28px;
+ bottom: 24px;
+ z-index: 12;
+ border-radius: 18px;
+ border: 1px solid rgba(255, 255, 255, 0.14);
+ background: var(--exhibit-glass-strong);
+ padding: 14px 18px;
+ backdrop-filter: blur(16px);
+}
+.thermal-callout__eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: 0.2em; color: #fff; }
+.thermal-callout__why { margin-top: 6px; font-size: 13px; line-height: 1.5; color: var(--exhibit-muted); }
+
+/* --- Secondary (collapsible) legacy panels ------------------------------ */
+.secondary-surfaces {
+ margin-top: 8px;
+ border-radius: var(--exhibit-radius-inner);
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.02);
+ padding: 8px 16px 16px;
+}
+.secondary-surfaces__summary {
+ cursor: pointer;
+ padding: 10px 4px;
+ font-size: 13px;
+ text-transform: uppercase;
+ letter-spacing: 0.16em;
+ color: var(--muted);
+}
+
+/* --- Reduced motion ----------------------------------------------------- */
+@media (prefers-reduced-motion: reduce) {
+ .exhibit-frame *,
+ .exhibit-stage * {
+ animation-duration: 0.001ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.001ms !important;
+ }
+}
diff --git a/tests/renderer/exhibits/ExhibitStage.test.tsx b/tests/renderer/exhibits/ExhibitStage.test.tsx
new file mode 100644
index 0000000..a7e9b97
--- /dev/null
+++ b/tests/renderer/exhibits/ExhibitStage.test.tsx
@@ -0,0 +1,135 @@
+// @vitest-environment jsdom
+
+import { act, fireEvent, render } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import ExhibitStage from '../../../src/renderer/exhibits/ExhibitStage';
+import fixtureGallery from '../../../src/renderer/exhibits/fixture-gallery';
+import type { ExhibitArtifact } from '../../../src/renderer/exhibits/types';
+
+function railTitles(container: HTMLElement): string[] {
+ return Array.from(container.querySelectorAll('.exhibit-rail__title')).map((el) => el.textContent ?? '');
+}
+
+function mainTitle(container: HTMLElement): string {
+ return container.querySelector('.exhibit-main__title')?.textContent ?? '';
+}
+
+function pillText(container: HTMLElement): string {
+ return container.querySelector('.exhibit-pill')?.textContent ?? '';
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe('ExhibitStage', () => {
+ it('renders the fixture gallery, leading with the highest-relevance exhibit', () => {
+ const { container } = render();
+ const activeSorted = fixtureGallery
+ .filter((a) => a.status !== 'retired')
+ .sort((a, b) => b.relevance - a.relevance);
+ // The main frame opens on the most relevant exhibit.
+ expect(mainTitle(container)).toBe(activeSorted[0].title);
+ // The rail lists more than one exhibit.
+ expect(railTitles(container).length).toBeGreaterThan(3);
+ });
+
+ it('orders the rail (rotation) by descending relevance', () => {
+ const { container } = render();
+ const expected = fixtureGallery
+ .filter((a) => a.status !== 'retired')
+ .sort((a, b) => b.relevance - a.relevance)
+ .map((a) => a.title);
+ expect(railTitles(container)).toEqual(expected);
+ });
+
+ it('advances through the rotation in relevance order on manual next', () => {
+ const { container } = render();
+ const expected = fixtureGallery
+ .filter((a) => a.status !== 'retired')
+ .sort((a, b) => b.relevance - a.relevance)
+ .map((a) => a.title);
+ expect(mainTitle(container)).toBe(expected[0]);
+ fireEvent.click(container.querySelector('[aria-label="Next exhibit"]') as Element);
+ expect(mainTitle(container)).toBe(expected[1]);
+ });
+
+ it('auto-cycles on an 8s idle timer to the next exhibit', () => {
+ vi.useFakeTimers();
+ const { container } = render();
+ const expected = fixtureGallery
+ .filter((a) => a.status !== 'retired')
+ .sort((a, b) => b.relevance - a.relevance)
+ .map((a) => a.title);
+ expect(mainTitle(container)).toBe(expected[0]);
+ act(() => {
+ vi.advanceTimersByTime(8000);
+ });
+ expect(mainTitle(container)).toBe(expected[1]);
+ });
+
+ it('pauses cycling on hover and resumes on leave (with a visible pill)', () => {
+ const { container } = render();
+ const stage = container.querySelector('.exhibit-stage') as HTMLElement;
+ expect(pillText(container)).toBe('cycling');
+ fireEvent.mouseEnter(stage);
+ expect(pillText(container)).toBe('paused');
+ fireEvent.mouseLeave(stage);
+ expect(pillText(container)).toBe('cycling');
+ });
+
+ it('does not auto-advance while paused', () => {
+ vi.useFakeTimers();
+ const { container } = render();
+ const stage = container.querySelector('.exhibit-stage') as HTMLElement;
+ const expected = fixtureGallery
+ .filter((a) => a.status !== 'retired')
+ .sort((a, b) => b.relevance - a.relevance)
+ .map((a) => a.title);
+ fireEvent.mouseEnter(stage);
+ act(() => {
+ vi.advanceTimersByTime(24000);
+ });
+ expect(mainTitle(container)).toBe(expected[0]);
+ });
+
+ it('collapses retired artifacts into the archive shelf, out of the rotation', () => {
+ const { container } = render();
+ const retired = fixtureGallery.filter((a) => a.status === 'retired');
+ expect(retired.length).toBeGreaterThan(0);
+
+ const archive = container.querySelector('.exhibit-archive') as HTMLElement;
+ expect(archive).toBeTruthy();
+ const archiveText = archive.textContent ?? '';
+ for (const artifact of retired) {
+ expect(archiveText).toContain(artifact.title);
+ // retirement reason is exposed on hover via the title attribute
+ const item = Array.from(archive.querySelectorAll('.exhibit-archive__item')).find((el) =>
+ (el.textContent ?? '').includes(artifact.title)
+ );
+ expect(item?.getAttribute('title')).toBe(artifact.retirementReason);
+ // and it is NOT part of the main rotation rail
+ expect(railTitles(container)).not.toContain(artifact.title);
+ }
+ });
+
+ it('renders the live_graph exhibit with the passed-through canvas node', () => {
+ const liveOnly: ExhibitArtifact[] = [
+ {
+ id: 'live-graph',
+ exhibitType: 'live_graph',
+ title: 'Live agent graph',
+ narrative: 'The live topology beneath the curated exhibits.',
+ relevance: 0.5,
+ decayClass: 'fast',
+ createdAtEvent: 1,
+ status: 'active',
+ payload: {},
+ },
+ ];
+ const { container } = render(
+ canvas} />
+ );
+ expect(container.querySelector('[data-testid="canvas-slot"]')).toBeTruthy();
+ });
+});
diff --git a/tests/renderer/exhibits/components.test.tsx b/tests/renderer/exhibits/components.test.tsx
new file mode 100644
index 0000000..b94380c
--- /dev/null
+++ b/tests/renderer/exhibits/components.test.tsx
@@ -0,0 +1,162 @@
+// @vitest-environment jsdom
+
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import RelationshipDag from '../../../src/renderer/exhibits/RelationshipDag';
+import ActivityNarrative from '../../../src/renderer/exhibits/ActivityNarrative';
+import Walkthrough from '../../../src/renderer/exhibits/Walkthrough';
+import ConcernSnapshot from '../../../src/renderer/exhibits/ConcernSnapshot';
+import Momentum from '../../../src/renderer/exhibits/Momentum';
+import Seismograph from '../../../src/renderer/exhibits/Seismograph';
+import ThermalMap from '../../../src/renderer/exhibits/ThermalMap';
+import type { ExhibitArtifactOf } from '../../../src/renderer/exhibits/types';
+
+function base
(exhibitType: T) {
+ return {
+ id: `${exhibitType}-1`,
+ exhibitType,
+ title: `Title ${exhibitType}`,
+ narrative: 'A narrative that explains what it means.',
+ relevance: 0.7,
+ decayClass: 'medium' as const,
+ createdAtEvent: 1,
+ status: 'active' as const,
+ };
+}
+
+describe('exhibit components render their key content', () => {
+ it('RelationshipDag shows nodes, edge labels, and the focused note', () => {
+ const artifact: ExhibitArtifactOf<'relationship_dag'> = {
+ ...base('relationship_dag'),
+ exhibitType: 'relationship_dag',
+ payload: {
+ focusNodeId: 'a',
+ nodes: [
+ { id: 'a', title: 'Alpha node', subtitle: 'sub a', kind: 'goal', x: 25, y: 40, note: 'Why alpha matters.' },
+ { id: 'b', title: 'Beta node', subtitle: 'sub b', kind: 'incident', x: 70, y: 55, note: 'Beta note.', urgent: true },
+ ],
+ edges: [{ from: 'a', to: 'b', label: 'blocks', strong: true }],
+ },
+ };
+ render();
+ // Alpha is the focused node, so its title appears in the node and focus card.
+ expect(screen.getAllByText('Alpha node').length).toBeGreaterThan(0);
+ expect(screen.getByText('Beta node')).toBeInTheDocument();
+ expect(screen.getByText('blocks')).toBeInTheDocument();
+ expect(screen.getByText('urgent')).toBeInTheDocument();
+ expect(screen.getByText('Why alpha matters.')).toBeInTheDocument();
+ });
+
+ it('ActivityNarrative shows beats and thread labels', () => {
+ const artifact: ExhibitArtifactOf<'activity_narrative'> = {
+ ...base('activity_narrative'),
+ exhibitType: 'activity_narrative',
+ payload: {
+ threads: [{ id: 'th', label: 'primary thread', color: 'rgba(1,2,3,0.5)' }],
+ beats: [
+ { at: '08:39', title: 'First beat', body: 'body one', side: 'top', thread: 'th' },
+ { at: '09:04', title: 'Second beat', body: 'body two', side: 'bottom', thread: 'th' },
+ ],
+ },
+ };
+ render();
+ expect(screen.getByText('First beat')).toBeInTheDocument();
+ expect(screen.getByText('Second beat')).toBeInTheDocument();
+ expect(screen.getAllByText('thread · th').length).toBe(2);
+ });
+
+ it('Walkthrough shows the headline, tags, satellites, and files', () => {
+ const artifact: ExhibitArtifactOf<'walkthrough'> = {
+ ...base('walkthrough'),
+ exhibitType: 'walkthrough',
+ payload: {
+ headline: 'The pivotal turn',
+ body: 'body copy',
+ tags: [{ label: 'match · scope', kind: 'match' }],
+ satellites: [{ id: 's1', label: 'step one', note: 'did a thing', x: 30, y: 30 }],
+ files: [{ label: 'src/thing.ts', x: 50, y: 20 }],
+ },
+ };
+ render();
+ expect(screen.getByText('The pivotal turn')).toBeInTheDocument();
+ expect(screen.getByText('match · scope')).toBeInTheDocument();
+ expect(screen.getByText('step one')).toBeInTheDocument();
+ expect(screen.getByText('src/thing.ts')).toBeInTheDocument();
+ });
+
+ it('ConcernSnapshot shows concern titles and roles', () => {
+ const artifact: ExhibitArtifactOf<'concern_snapshot'> = {
+ ...base('concern_snapshot'),
+ exhibitType: 'concern_snapshot',
+ payload: {
+ concerns: [
+ { id: 'c1', title: 'Image build', role: 'the unblock', x: 10, y: 16, w: 24, h: 22, heat: 'very-high' },
+ { id: 'c2', title: 'Cutovers', role: 'not started', x: 60, y: 16, w: 24, h: 22, heat: 'low' },
+ ],
+ flows: [['c1', 'c2']],
+ },
+ };
+ render();
+ // Image build is the selected concern, so it appears in the box and focus card.
+ expect(screen.getAllByText('Image build').length).toBeGreaterThan(0);
+ expect(screen.getByText('Cutovers')).toBeInTheDocument();
+ // role appears both in the box and the focus card
+ expect(screen.getAllByText('the unblock').length).toBeGreaterThan(0);
+ });
+
+ it('Momentum shows the label, stats, and next items', () => {
+ const artifact: ExhibitArtifactOf<'momentum'> = {
+ ...base('momentum'),
+ exhibitType: 'momentum',
+ payload: {
+ value: 44,
+ label: 'Parked mid-path',
+ stats: [{ label: 'Cutovers', value: 'not started', tone: 'negative' }],
+ next: [{ title: 'Rotate the token', evidence: 'exposed in-session', confidence: 88 }],
+ curation: [{ artifactId: 'x', action: 'retire', reason: 'stopped mattering' }],
+ },
+ };
+ render();
+ expect(screen.getByText('Parked mid-path')).toBeInTheDocument();
+ expect(screen.getByText('not started')).toBeInTheDocument();
+ expect(screen.getByText('Rotate the token')).toBeInTheDocument();
+ expect(screen.getByText('88%')).toBeInTheDocument();
+ });
+
+ it('Seismograph shows tremor labels and the window chip', () => {
+ const artifact: ExhibitArtifactOf<'seismograph'> = {
+ ...base('seismograph'),
+ exhibitType: 'seismograph',
+ payload: {
+ windowMinutes: 190,
+ trace: [
+ { at: '10:12', magnitude: -0.9, kind: 'failure', label: 'GHCR 403 stall' },
+ { at: '11:20', magnitude: 0.6, kind: 'merge', label: 'recovery proven' },
+ ],
+ annotations: [{ at: '10:12', text: 'credential wall' }],
+ },
+ };
+ render();
+ expect(screen.getByText('GHCR 403 stall')).toBeInTheDocument();
+ expect(screen.getByText('recovery proven')).toBeInTheDocument();
+ expect(screen.getByText('190 min window')).toBeInTheDocument();
+ });
+
+ it('ThermalMap shows cells and the hottest callout', () => {
+ const artifact: ExhibitArtifactOf<'thermal_map'> = {
+ ...base('thermal_map'),
+ exhibitType: 'thermal_map',
+ payload: {
+ cells: [
+ { path: 'db/image', weight: 5, heat: 0.95, label: 'image' },
+ { path: 'db/cutover', weight: 2, heat: 0.1, label: 'cutover' },
+ ],
+ hottest: { path: 'db/image', why: 'most attention spent here' },
+ },
+ };
+ render();
+ expect(screen.getByText('image')).toBeInTheDocument();
+ expect(screen.getByText('cutover')).toBeInTheDocument();
+ expect(screen.getByText('most attention spent here')).toBeInTheDocument();
+ });
+});
diff --git a/tests/renderer/exhibits/types.test.ts b/tests/renderer/exhibits/types.test.ts
new file mode 100644
index 0000000..2c595b0
--- /dev/null
+++ b/tests/renderer/exhibits/types.test.ts
@@ -0,0 +1,87 @@
+import { describe, expect, it } from 'vitest';
+import {
+ AUTHORED_EXHIBIT_TYPES,
+ isActivityNarrative,
+ isConcernSnapshot,
+ isExhibitOfType,
+ isLiveGraph,
+ isMomentum,
+ isRelationshipDag,
+ isRetired,
+ isSeismograph,
+ isThermalMap,
+ isWalkthrough,
+ type ExhibitArtifact,
+} from '../../../src/renderer/exhibits/types';
+import fixtureGallery from '../../../src/renderer/exhibits/fixture-gallery';
+
+function envelope(
+ exhibitType: T,
+ payload: Extract['payload']
+): ExhibitArtifact {
+ return {
+ id: `${exhibitType}-1`,
+ exhibitType,
+ title: 't',
+ narrative: 'n',
+ relevance: 0.5,
+ decayClass: 'medium',
+ createdAtEvent: 1,
+ status: 'active',
+ payload,
+ } as ExhibitArtifact;
+}
+
+describe('exhibit type guards', () => {
+ it('narrows each authored type to exactly one guard', () => {
+ const cases: Array<[ExhibitArtifact, (a: ExhibitArtifact) => boolean]> = [
+ [envelope('relationship_dag', { nodes: [], edges: [], focusNodeId: '' }), isRelationshipDag],
+ [envelope('activity_narrative', { beats: [], threads: [] }), isActivityNarrative],
+ [envelope('walkthrough', { headline: '', body: '', tags: [], satellites: [], files: [] }), isWalkthrough],
+ [envelope('concern_snapshot', { concerns: [], flows: [] }), isConcernSnapshot],
+ [envelope('momentum', { value: 0, label: '', stats: [], next: [], curation: [] }), isMomentum],
+ [envelope('seismograph', { trace: [], annotations: [], windowMinutes: 60 }), isSeismograph],
+ [envelope('thermal_map', { cells: [], hottest: { path: '', why: '' } }), isThermalMap],
+ [envelope('live_graph', {}), isLiveGraph],
+ ];
+
+ const guards = cases.map(([, guard]) => guard);
+ for (const [artifact, ownGuard] of cases) {
+ expect(ownGuard(artifact)).toBe(true);
+ const others = guards.filter((g) => g !== ownGuard);
+ for (const other of others) {
+ expect(other(artifact)).toBe(false);
+ }
+ }
+ });
+
+ it('isExhibitOfType narrows generically', () => {
+ const dag = envelope('relationship_dag', { nodes: [], edges: [], focusNodeId: '' });
+ expect(isExhibitOfType(dag, 'relationship_dag')).toBe(true);
+ expect(isExhibitOfType(dag, 'momentum')).toBe(false);
+ });
+
+ it('isRetired reflects status', () => {
+ const active = envelope('live_graph', {});
+ expect(isRetired(active)).toBe(false);
+ expect(isRetired({ ...active, status: 'retired' })).toBe(true);
+ });
+
+ it('AUTHORED_EXHIBIT_TYPES excludes live_graph and lists the seven authored types', () => {
+ expect(AUTHORED_EXHIBIT_TYPES).toHaveLength(7);
+ expect(AUTHORED_EXHIBIT_TYPES).not.toContain('live_graph');
+ });
+
+ it('the fixture gallery is internally consistent', () => {
+ // Unique ids, mandatory narrative, relevance in range, at least one retired.
+ const ids = new Set(fixtureGallery.map((a) => a.id));
+ expect(ids.size).toBe(fixtureGallery.length);
+ for (const artifact of fixtureGallery) {
+ expect(artifact.narrative.length).toBeGreaterThan(20);
+ expect(artifact.relevance).toBeGreaterThanOrEqual(0);
+ expect(artifact.relevance).toBeLessThanOrEqual(1);
+ }
+ expect(fixtureGallery.some(isRetired)).toBe(true);
+ expect(fixtureGallery.some(isLiveGraph)).toBe(true);
+ });
+});
diff --git a/tests/setup.ts b/tests/setup.ts
index bb02c60..b335f58 100644
--- a/tests/setup.ts
+++ b/tests/setup.ts
@@ -1 +1,20 @@
import '@testing-library/jest-dom/vitest';
+
+// jsdom does not implement matchMedia. framer-motion's useReducedMotion and the
+// canvas reduced-motion effect both probe it, so provide a stub that reports
+// "no preference" and supports the (un)subscribe surface they use.
+if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') {
+ window.matchMedia = (query: string): MediaQueryList => {
+ const list: MediaQueryList = {
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false
+ };
+ return list;
+ };
+}