diff --git a/packages/viewer/src/explore/ExploreContext.test.tsx b/packages/viewer/src/explore/ExploreContext.test.tsx
new file mode 100644
index 0000000..8460b28
--- /dev/null
+++ b/packages/viewer/src/explore/ExploreContext.test.tsx
@@ -0,0 +1,54 @@
+import { act, render } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+import { commerceApiLayout } from '../samples/commerce-api.layout';
+import { ExploreProvider, useExplore } from './ExploreContext';
+import type { Emphasis } from './emphasis';
+import type { ExploreAction } from './state';
+
+const draft = commerceApiLayout.draft;
+const first = draft.nodes[0]!;
+
+afterEach(() => history.replaceState(null, '', location.pathname));
+
+function mount() {
+ const seen: Emphasis[] = [];
+ let send!: (a: ExploreAction) => void;
+ function Probe() {
+ const { emphasis, dispatch } = useExplore();
+ seen.push(emphasis);
+ send = dispatch;
+ return null;
+ }
+ render(
+
+
+ ,
+ );
+ return { latest: () => seen[seen.length - 1]!, dispatch: (a: ExploreAction) => act(() => send(a)) };
+}
+
+describe('ExploreProvider emphasis', () => {
+ it('keeps the same emphasis through play and speed changes', () => {
+ const { latest, dispatch } = mount();
+ const before = latest();
+ dispatch({ type: 'togglePlay' });
+ dispatch({ type: 'cycleSpeed' });
+ expect(latest()).toBe(before);
+ });
+
+ it('rebuilds emphasis when the selection changes', () => {
+ const { latest, dispatch } = mount();
+ const before = latest();
+ dispatch({ type: 'select', id: first.id });
+ expect(latest()).not.toBe(before);
+ expect(latest().nodes.get(first.id)).toBe('focus');
+ });
+
+ it('rebuilds emphasis when a type is hidden', () => {
+ const { latest, dispatch } = mount();
+ const before = latest();
+ dispatch({ type: 'toggleType', nodeType: first.type });
+ expect(latest()).not.toBe(before);
+ expect(latest().nodes.get(first.id)).toBe('dim');
+ });
+});
diff --git a/packages/viewer/src/explore/ExploreContext.tsx b/packages/viewer/src/explore/ExploreContext.tsx
index 38e721e..32fd7e7 100644
--- a/packages/viewer/src/explore/ExploreContext.tsx
+++ b/packages/viewer/src/explore/ExploreContext.tsx
@@ -49,7 +49,12 @@ export function ExploreProvider({
const known = useMemo(() => knownOf(draft), [draft]);
const [state, dispatch] = useReducer(explore, undefined, () => (syncHash ? parseHash(location.hash, known) : INITIAL));
const graph = useMemo(() => buildGraph(draft.nodes.map((n) => n.id), draft.edges, { timed: draft.kind === 'sequence' }), [draft]);
- const em = useMemo(() => emphasis(draft, graph, state), [draft, graph, state]);
+ const { peek, view, query, trace, selected, route, routing, hiddenTypes } = state;
+ // Only what emphasis reads: a speed or play toggle must not rebuild it, or the playing flow restarts from 0.
+ const em = useMemo(
+ () => emphasis(draft, graph, { peek, view, query, trace, selected, route, routing, hiddenTypes }),
+ [draft, graph, peek, view, query, trace, selected, route, routing, hiddenTypes],
+ );
const stateRef = useRef(state);
stateRef.current = state;
diff --git a/packages/viewer/src/explore/emphasis.ts b/packages/viewer/src/explore/emphasis.ts
index 6d510e5..97f59f0 100644
--- a/packages/viewer/src/explore/emphasis.ts
+++ b/packages/viewer/src/explore/emphasis.ts
@@ -26,7 +26,10 @@ export function searchMatches(draft: DiagramDraft, query: string): DiagramNode[]
return hits.map((n, i) => ({ n, i })).sort((a, b) => prefix(a.n) - prefix(b.n) || a.i - b.i).map((x) => x.n);
}
-export function emphasis(draft: DiagramDraft, graph: Graph, s: ExploreState): Emphasis {
+/** The explorer state emphasis reads; playback fields (playing, speed) and reveal don't change what's lit. */
+export type EmphasisInput = Pick;
+
+export function emphasis(draft: DiagramDraft, graph: Graph, s: EmphasisInput): Emphasis {
if (s.peek) return peekAt(draft, s.peek);
const view = s.view ? new Set(draft.views?.find((v) => v.id === s.view)?.nodes ?? []) : null;
const q = s.query?.trim() ? new Set(searchMatches(draft, s.query).map((n) => n.id)) : null;