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) => { + if (idx === 0) return null; + const ax = 10 + (idx - 1) * step; + const bx = 10 + idx * step; + const sameThread = beats[idx - 1].thread === beat.thread; + const y = sameThread ? 47 : 53; + const shown = idx <= active; + return ( + + ); + })} + + + {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 +
+ + + {flows.map(([aId, bId], idx) => { + const a = byId[aId]; + const b = byId[bId]; + if (!a || !b) return null; + const from = { x: a.x + a.w / 2, y: a.y + a.h / 2 }; + const to = { x: b.x + b.w / 2, y: b.y + b.h / 2 }; + return ( + + ); + })} + + + {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}
+
+
+
+ + + + + + + + {Math.round(clamped)} + + +
+ {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 +
+ + + + + + + + + + + + + {edges.map((edge, idx) => { + const a = byId[edge.from]; + const b = byId[edge.to]; + if (!a || !b) return null; + return ( + + + + {edge.label} + + + ); + })} + + + {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 +
+ + + {/* time axis */} + + {[0.25, 0.5, 0.75].map((f) => ( + + ))} + + {/* individual kind-colored spikes */} + {points.map((p, idx) => ( + + ))} + + {/* the trace draws on left-to-right */} + + + {/* peak dots */} + {points.map((p, idx) => ( + + ))} + + + {/* 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 +
+ + + {satellites.map((sat, idx) => ( + + ))} + {files.map((file, idx) => ( + + ))} + + + +
{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 ( +
+
+
+
+ {children} +
+ ); +} + +/** 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; + }; +}