@crvouga/atlas 1.11.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/dist/critics/src/contract.d.ts +113 -0
  3. package/dist/critics/src/contract.js +48 -0
  4. package/dist/critics/src/credentials.d.ts +72 -0
  5. package/dist/critics/src/credentials.js +136 -0
  6. package/dist/critics/src/critique.d.ts +128 -0
  7. package/dist/critics/src/critique.js +163 -0
  8. package/dist/critics/src/index.d.ts +21 -0
  9. package/dist/critics/src/index.js +21 -0
  10. package/dist/critics/src/judging.d.ts +153 -0
  11. package/dist/critics/src/judging.js +145 -0
  12. package/dist/critics/src/publish.d.ts +23 -0
  13. package/dist/critics/src/publish.js +21 -0
  14. package/dist/critics/src/runner.d.ts +104 -0
  15. package/dist/critics/src/runner.js +202 -0
  16. package/dist/critics/src/scope.d.ts +8 -0
  17. package/dist/critics/src/scope.js +26 -0
  18. package/dist/reports/src/compose.d.ts +2 -2
  19. package/dist/reports/src/compose.js +6 -5
  20. package/dist/reports/src/evidence-view.d.ts +2 -1
  21. package/dist/reports/src/evidence-view.js +5 -2
  22. package/dist/reports/src/layer-merge.d.ts +11 -3
  23. package/dist/reports/src/layer-merge.js +14 -3
  24. package/dist/reports/src/report-status.d.ts +1 -1
  25. package/dist/reports/src/report-status.js +6 -4
  26. package/dist/schema/src/api.d.ts +1 -0
  27. package/dist/schema/src/api.js +1 -1
  28. package/dist/schema/src/critique.d.ts +144 -0
  29. package/dist/schema/src/critique.js +96 -0
  30. package/dist/schema/src/index.d.ts +2 -1
  31. package/dist/schema/src/index.js +2 -1
  32. package/dist/schema/src/server-config.d.ts +7 -1
  33. package/dist/schema/src/server-config.js +6 -3
  34. package/dist/server/src/auth.d.ts +1 -1
  35. package/dist/server/src/auth.js +2 -2
  36. package/dist/server/src/config.js +2 -2
  37. package/dist/server/src/openapi.d.ts +3 -3
  38. package/dist/server/src/openapi.js +7 -4
  39. package/dist/server/src/store-api.js +39 -1
  40. package/docs/agents.md +1 -0
  41. package/docs/critics.md +190 -0
  42. package/docs/extending.md +8 -0
  43. package/docs/reference/critics.md +840 -0
  44. package/docs/reference/http-api.md +6 -2
  45. package/docs/reference/reports.md +20 -7
  46. package/docs/reference/schema.md +119 -2
  47. package/docs/reference/server.md +3 -3
  48. package/docs/report-contract.md +4 -0
  49. package/docs/rich-evidence.md +39 -0
  50. package/docs/server.md +6 -4
  51. package/docs/ui.md +22 -0
  52. package/llms-full.txt +279 -6
  53. package/llms.txt +2 -0
  54. package/package.json +7 -2
  55. package/packages/critics/README.md +18 -0
  56. package/packages/schema/schemas/critique.schema.json +254 -0
  57. package/packages/schema/schemas/server-config.schema.json +2 -1
  58. package/packages/schema/schemas/server-info.schema.json +1 -0
  59. package/packages/server/openapi.json +7 -6
  60. package/packages/visualizer/src/app/router.tsx +3 -0
  61. package/packages/visualizer/src/evidence/artifacts.tsx +88 -42
  62. package/packages/visualizer/src/evidence/behavior-panel.tsx +44 -25
  63. package/packages/visualizer/src/evidence/media-timing.test.ts +85 -0
  64. package/packages/visualizer/src/evidence/media-timing.ts +150 -0
  65. package/packages/visualizer/src/evidence/moments.tsx +457 -0
  66. package/packages/visualizer/src/evidence/outcome-summary.tsx +27 -0
  67. package/packages/visualizer/src/evidence/outcome.ts +7 -2
  68. package/packages/visualizer/src/explorer/TestDetail.tsx +2 -2
  69. package/packages/visualizer/src/explorer/playlist.tsx +89 -21
  70. package/packages/visualizer/src/explorer/search.ts +7 -1
@@ -0,0 +1,457 @@
1
+ /**
2
+ * @module Recording moments
3
+ *
4
+ * Connects a check's steps to its recordings. Inside a {@link MomentPlayback}, a step offers to show itself in the
5
+ * selected recording (its place comes from the recording's own timing, `media-timing.ts`, never from an assumption that
6
+ * the check and the recording started together), playback marks the step that is playing without moving focus, the
7
+ * failing step can be jumped to, and the current moment can be copied as a link that names the run, the recording and
8
+ * the position. A step that cannot be placed says why and nothing is guessed. One provider serves one holder (a check
9
+ * or one of its retries): a retry's steps are placed in that retry's own recording with that recording's own timing.
10
+ */
11
+ import { attachmentIdentity, isVideoAttachment, type EvidenceHolder } from '@crvouga/atlas/reports/evidence-detail';
12
+ import { effectiveScope } from '@crvouga/atlas/reports/evidence-scope';
13
+ import type { Attachment, Step } from '@crvouga/atlas/schema';
14
+ import { Alert, Button, Group, NativeSelect, Stack, Text, TextInput } from '@mantine/core';
15
+ import { IconAlertTriangle, IconLink, IconPlayerPlay } from '@tabler/icons-react';
16
+ import { useRouter } from '@tanstack/react-router';
17
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, type SyntheticEvent } from 'react';
18
+
19
+ import { useScope } from './evidence-scope';
20
+ import { duration } from './format';
21
+ import { activeIndex, alignStep, clock, mediaTimingOf, pausedMs, type Alignment, type MediaTiming } from './media-timing';
22
+ import { failedStepOf } from './outcome';
23
+
24
+ /** One recording whose clock the steps can be placed on. */
25
+ export type MomentVideo = { readonly id: string; readonly attachment: Attachment; readonly timing: MediaTiming | undefined };
26
+
27
+ /** One step and where it is in the selected recording. */
28
+ export type Moment = { readonly index: number; readonly step: Step; readonly alignment: Alignment };
29
+
30
+ /** What asking for a moment came to. */
31
+ export type MomentOutcome = { readonly seeked: boolean; readonly text: string };
32
+
33
+ type Notice = { readonly text: string };
34
+
35
+ type Playback = {
36
+ readonly videos: readonly MomentVideo[];
37
+ readonly selected: MomentVideo | null;
38
+ readonly select: (id: string) => void;
39
+ readonly momentOf: (step: Step) => Moment | undefined;
40
+ /** The index (in the holder's steps) of the step that is playing. */
41
+ readonly active: number | null;
42
+ readonly notice: Notice | null;
43
+ readonly seek: (index: number) => MomentOutcome;
44
+ readonly seekFailure: () => MomentOutcome;
45
+ readonly failed: Step | undefined;
46
+ /** How many steps the holder recorded. */
47
+ readonly stepCount: number;
48
+ readonly currentMs: () => number | undefined;
49
+ readonly linkTo: (atMs: number) => string | null;
50
+ register: (id: string, element: HTMLVideoElement | null) => void;
51
+ loaded: (id: string, element: HTMLVideoElement) => void;
52
+ progress: (id: string, element: HTMLVideoElement) => void;
53
+ };
54
+
55
+ const Around = createContext<Playback | null>(null);
56
+
57
+ /** The playback of the holder around this point, null outside one. */
58
+ export function usePlayback(): Playback | null {
59
+ return useContext(Around);
60
+ }
61
+
62
+ /** The window event by which a part of the page asks the check's player for a moment (see {@link requestFailureMoment}). */
63
+ const REQUEST = 'atlas:moment-request';
64
+ type Request = { kind: 'failure'; owner: string; handled?: MomentOutcome };
65
+
66
+ /** Names the one result of a check, so a request reaches the player of that result and not another open beside it. */
67
+ export function momentOwner(layer: string, kind: string, check: string): string {
68
+ return `${layer}|${kind}|${check}`;
69
+ }
70
+
71
+ /**
72
+ * Asks the player of the result named by `owner` (see {@link momentOwner}) to jump to the step where it failed. Returns
73
+ * what came of it, or null when no player of that result is on screen (nothing is read or played on its behalf).
74
+ */
75
+ export function requestFailureMoment(owner: string): MomentOutcome | null {
76
+ const detail: Request = { kind: 'failure', owner };
77
+ window.dispatchEvent(new CustomEvent(REQUEST, { detail }));
78
+ return detail.handled ?? null;
79
+ }
80
+
81
+ /** The moment an address names: the recording (an `attachmentIdentity`), a position in it, and the run's layer. */
82
+ export type RequestedMoment = { readonly clip: string | undefined; readonly atMs: number | undefined; readonly layer: string | undefined };
83
+
84
+ type RouterLike = {
85
+ state: { location: { pathname: string; search: unknown } };
86
+ buildLocation: (options: { to: string; search: unknown }) => { href: string };
87
+ history: { createHref: (href: string) => string };
88
+ };
89
+
90
+ function text(value: unknown): string | undefined {
91
+ return typeof value === 'string' && value !== '' ? value : typeof value === 'number' ? String(value) : undefined;
92
+ }
93
+
94
+ /** The moment the address names, read when it is called; all undefined when the address names none or there is no router. */
95
+ export function useRequestedMoment(): RequestedMoment {
96
+ const router = useRouter({ warn: false }) as unknown as RouterLike | undefined;
97
+ const search = (router?.state.location.search ?? {}) as Record<string, unknown>;
98
+ const at = Number(search.clipAt);
99
+ return { clip: text(search.clip), atMs: search.clipAt !== undefined && Number.isFinite(at) && at >= 0 ? at : undefined, layer: text(search.layer) };
100
+ }
101
+
102
+ /** The recordings among `attachments` that name bytes, each once, with the timing they declare. */
103
+ function momentVideos(attachments: readonly Attachment[]): MomentVideo[] {
104
+ const seen = new Set<string>();
105
+ return attachments.flatMap((attachment) => {
106
+ const id = isVideoAttachment(attachment) ? attachmentIdentity(attachment) : undefined;
107
+ if (id === undefined || seen.has(id)) return [];
108
+ seen.add(id);
109
+ return [{ id, attachment, timing: mediaTimingOf(attachment) }];
110
+ });
111
+ }
112
+
113
+ const NONE: readonly Step[] = [];
114
+
115
+ /**
116
+ * The steps that belong to the recording with this identity in a check's evidence: those of the check when it (or one of
117
+ * its steps) carries the recording, else those of the retry that does. A retry's recording is never read with another
118
+ * try's steps.
119
+ */
120
+ export function stepsOfRecording(check: EvidenceHolder, identity: string | undefined): readonly Step[] {
121
+ const carries = (attachments: readonly Attachment[] | undefined, steps: readonly Step[] | undefined) =>
122
+ identity !== undefined &&
123
+ [...(attachments ?? []), ...(steps ?? []).flatMap((step) => step.attachments ?? [])].some((item) => attachmentIdentity(item) === identity);
124
+ if (carries(check.attachments, check.steps)) return check.steps ?? NONE;
125
+ const attempt = (check.attempts ?? []).find((each) => carries(each.attachments, each.steps));
126
+ return (attempt ? attempt.steps : check.steps) ?? NONE;
127
+ }
128
+
129
+ /**
130
+ * Places the steps of one holder in its recordings. `attachments` are the holder's own files (the videos among them are
131
+ * the recordings); `steps` are its timeline. `primary` marks the check itself, which answers
132
+ * {@link requestFailureMoment}. `linkSearch` is merged into the current address to make a link to a moment (by default
133
+ * the exact `layer`). When the address names one of the recordings (`clip`, `clipAt`), that recording is selected and the
134
+ * player goes to the position once it can.
135
+ */
136
+ export function MomentPlayback({
137
+ steps = NONE,
138
+ attachments,
139
+ layer,
140
+ primary = false,
141
+ owner,
142
+ linkSearch,
143
+ children
144
+ }: {
145
+ steps?: readonly Step[];
146
+ attachments: readonly Attachment[];
147
+ layer: string;
148
+ primary?: boolean;
149
+ owner?: string;
150
+ linkSearch?: Record<string, unknown>;
151
+ children: ReactNode;
152
+ }) {
153
+ const router = useRouter({ warn: false }) as unknown as RouterLike | undefined;
154
+ const requested = useRequestedMoment();
155
+ const videos = useMemo(() => momentVideos(attachments), [attachments]);
156
+ const [chosen, setChosen] = useState<string | null>(() =>
157
+ requested.clip !== undefined && videos.some((video) => video.id === requested.clip)
158
+ ? requested.clip
159
+ : ((videos.find((video) => video.timing && !video.timing.problem) ?? videos[0])?.id ?? null)
160
+ );
161
+ const selected = videos.find((video) => video.id === chosen) ?? videos[0] ?? null;
162
+ const elements = useRef(new Map<string, HTMLVideoElement>());
163
+ const pending = useRef<{ id: string; atMs: number } | null>(
164
+ requested.clip !== undefined && requested.atMs !== undefined && videos.some((video) => video.id === requested.clip)
165
+ ? { id: requested.clip, atMs: requested.atMs }
166
+ : null
167
+ );
168
+ const [lengths, setLengths] = useState<Readonly<Record<string, number>>>({});
169
+ const [active, setActive] = useState<number | null>(null);
170
+ const [notice, setNotice] = useState<Notice | null>(null);
171
+
172
+ const lengthMs = selected ? (lengths[selected.id] ?? selected.attachment.durationMs) : undefined;
173
+ const moments = useMemo<Moment[]>(
174
+ () => steps.map((step, index) => ({ index, step, alignment: alignStep(step, selected?.timing, lengthMs) })),
175
+ [steps, selected, lengthMs]
176
+ );
177
+ const byStep = useMemo(() => new Map(moments.map((moment) => [moment.step, moment])), [moments]);
178
+ const placed = useMemo(
179
+ () => moments.flatMap((moment) => (moment.alignment.state === 'aligned' ? [[moment.index, moment.alignment.mediaMs] as const] : [])),
180
+ [moments]
181
+ );
182
+
183
+ const goTo = useCallback(
184
+ (element: HTMLVideoElement | undefined, atMs: number): MomentOutcome => {
185
+ if (!element) return { seeked: false, text: 'The recording is not loaded, so it cannot be moved.' };
186
+ const length = Number.isFinite(element.duration) && element.duration > 0 ? element.duration * 1000 : undefined;
187
+ if (length !== undefined && atMs > length + 1) {
188
+ const text = `${clock(atMs)} is after the end of the recording, which is ${clock(length)} long. Nothing was moved.`;
189
+ setNotice({ text });
190
+ return { seeked: false, text };
191
+ }
192
+ element.currentTime = atMs / 1000;
193
+ setActive(activeIndex(placed, atMs));
194
+ setNotice(null);
195
+ // The page scrolls to the player; focus stays where it is.
196
+ element.scrollIntoView?.({ block: 'nearest' });
197
+ return { seeked: true, text: `Moved the recording to ${clock(atMs)}.` };
198
+ },
199
+ [placed]
200
+ );
201
+
202
+ const seek = useCallback(
203
+ (index: number): MomentOutcome => {
204
+ const moment = moments[index];
205
+ if (!moment || !selected) return { seeked: false, text: 'This result has no recording to place the step in.' };
206
+ if (moment.alignment.state === 'unavailable') {
207
+ const text = `“${moment.step.name}” cannot be shown in the recording. ${moment.alignment.reason}`;
208
+ setNotice({ text });
209
+ return { seeked: false, text };
210
+ }
211
+ return goTo(elements.current.get(selected.id), moment.alignment.mediaMs);
212
+ },
213
+ [moments, selected, goTo]
214
+ );
215
+
216
+ const failed = useMemo(() => failedStepOf(steps), [steps]);
217
+ const seekFailure = useCallback((): MomentOutcome => {
218
+ if (!failed) return { seeked: false, text: 'No failed step was recorded, so there is no failing moment to show.' };
219
+ return seek(steps.indexOf(failed));
220
+ }, [failed, steps, seek]);
221
+
222
+ useEffect(() => {
223
+ if (!primary || owner === undefined) return;
224
+ const answer = (event: Event) => {
225
+ const request = (event as CustomEvent<Request>).detail;
226
+ if (request?.kind === 'failure' && request.owner === owner && request.handled === undefined) request.handled = seekFailure();
227
+ };
228
+ window.addEventListener(REQUEST, answer);
229
+ return () => window.removeEventListener(REQUEST, answer);
230
+ }, [primary, owner, seekFailure]);
231
+
232
+ const progress = useCallback(
233
+ (id: string, element: HTMLVideoElement) => {
234
+ if (id === selected?.id) setActive(activeIndex(placed, element.currentTime * 1000));
235
+ },
236
+ [selected, placed]
237
+ );
238
+ const register = useCallback((id: string, element: HTMLVideoElement | null) => {
239
+ if (element) elements.current.set(id, element);
240
+ else elements.current.delete(id);
241
+ }, []);
242
+ const loaded = useCallback(
243
+ (id: string, element: HTMLVideoElement) => {
244
+ if (Number.isFinite(element.duration) && element.duration > 0) setLengths((held) => ({ ...held, [id]: element.duration * 1000 }));
245
+ const wanted = pending.current;
246
+ if (wanted && wanted.id === id) {
247
+ pending.current = null;
248
+ goTo(element, wanted.atMs);
249
+ }
250
+ },
251
+ [goTo]
252
+ );
253
+
254
+ const linkTo = useCallback(
255
+ (atMs: number): string | null => {
256
+ if (!router || !selected) return null;
257
+ try {
258
+ const built = router.buildLocation({
259
+ to: router.state.location.pathname,
260
+ search: (prev: Record<string, unknown>) => ({ ...prev, ...(linkSearch ?? { layer }), clip: selected.id, clipAt: Math.round(atMs) })
261
+ });
262
+ return new URL(router.history.createHref(built.href), window.location.href).href;
263
+ } catch {
264
+ return null;
265
+ }
266
+ },
267
+ [router, selected, linkSearch, layer]
268
+ );
269
+
270
+ const value = useMemo<Playback>(
271
+ () => ({
272
+ videos,
273
+ selected,
274
+ select: (id) => {
275
+ setChosen(id);
276
+ setActive(null);
277
+ setNotice(null);
278
+ },
279
+ momentOf: (step) => byStep.get(step),
280
+ active,
281
+ notice,
282
+ seek,
283
+ seekFailure,
284
+ failed,
285
+ stepCount: steps.length,
286
+ currentMs: () => {
287
+ const element = selected ? elements.current.get(selected.id) : undefined;
288
+ return element ? element.currentTime * 1000 : undefined;
289
+ },
290
+ linkTo,
291
+ register,
292
+ loaded,
293
+ progress
294
+ }),
295
+ [videos, selected, byStep, active, notice, seek, seekFailure, failed, steps, linkTo, register, loaded, progress]
296
+ );
297
+ return <Around.Provider value={value}>{children}</Around.Provider>;
298
+ }
299
+
300
+ /** The props that join a `<video>` of `attachment` to the playback around it; undefined when it is not one of its recordings. */
301
+ export function useMomentVideo(attachment: Attachment):
302
+ | {
303
+ ref: (element: HTMLVideoElement | null) => void;
304
+ onLoadedMetadata: (event: SyntheticEvent<HTMLVideoElement>) => void;
305
+ onTimeUpdate: (event: SyntheticEvent<HTMLVideoElement>) => void;
306
+ onSeeked: (event: SyntheticEvent<HTMLVideoElement>) => void;
307
+ }
308
+ | undefined {
309
+ const playback = usePlayback();
310
+ const id = isVideoAttachment(attachment) ? attachmentIdentity(attachment) : undefined;
311
+ const member = playback !== null && id !== undefined && playback.videos.some((video) => video.id === id);
312
+ const register = playback?.register;
313
+ const ref = useCallback(
314
+ (element: HTMLVideoElement | null) => {
315
+ if (id !== undefined) register?.(id, element);
316
+ },
317
+ [register, id]
318
+ );
319
+ if (!playback || !member || id === undefined) return undefined;
320
+ return {
321
+ ref,
322
+ onLoadedMetadata: (event) => playback.loaded(id, event.currentTarget),
323
+ onTimeUpdate: (event) => playback.progress(id, event.currentTarget),
324
+ onSeeked: (event) => playback.progress(id, event.currentTarget)
325
+ };
326
+ }
327
+
328
+ const label = (attachment: Attachment) => attachment.description ?? attachment.name;
329
+
330
+ /**
331
+ * A step's control in a timeline: shows the step in the selected recording at its recorded moment, or says that it
332
+ * cannot be placed. Renders nothing outside a playback with a recording.
333
+ */
334
+ export function SeekStep({ step }: { step: Step }) {
335
+ const playback = usePlayback();
336
+ const moment = playback?.momentOf(step);
337
+ if (!playback || !moment || !playback.selected) return null;
338
+ const aligned = moment.alignment.state === 'aligned';
339
+ const at = moment.alignment.state === 'aligned' ? clock(moment.alignment.mediaMs) : '';
340
+ return (
341
+ <Button
342
+ type="button"
343
+ size="compact-xs"
344
+ variant="subtle"
345
+ data-seek={aligned ? 'aligned' : 'unavailable'}
346
+ leftSection={<IconPlayerPlay size={14} aria-hidden="true" />}
347
+ aria-label={aligned ? `Show “${step.name}” in the recording at ${at}` : `Show “${step.name}” in the recording (alignment unavailable)`}
348
+ onClick={() => playback.seek(moment.index)}
349
+ >
350
+ {aligned ? `Recording ${at}` : 'Not aligned'}
351
+ </Button>
352
+ );
353
+ }
354
+
355
+ /** Whether the step is the one playing, so its row can say so. */
356
+ export function useIsPlaying(step: Step): boolean {
357
+ const playback = usePlayback();
358
+ const moment = playback?.momentOf(step);
359
+ return moment !== undefined && playback?.active === moment.index;
360
+ }
361
+
362
+ /**
363
+ * What the playback says above a timeline: which recording the steps are placed in and how, the pacing of a paced
364
+ * recording (never application latency), the jump to the failing moment, the link to the current moment, and why a
365
+ * step could not be shown. Renders nothing outside a playback with a recording.
366
+ */
367
+ export function MomentBar() {
368
+ const playback = usePlayback();
369
+ const scope = useScope();
370
+ const [copied, setCopied] = useState<'copied' | string | null>(null);
371
+ const [jump, setJump] = useState<string | null>(null);
372
+ if (!playback || !playback.selected) return null;
373
+ const { selected, videos, notice, failed } = playback;
374
+ const scoped = effectiveScope(selected.attachment.scope, scope);
375
+ const timing = selected.timing;
376
+ const paused = pausedMs(timing);
377
+ const reasons = [...new Set((timing?.pauses ?? []).flatMap((pause) => (pause.reason ? [pause.reason] : [])))];
378
+ const paced = scoped?.pacing === 'paced' || (timing?.pauses.length ?? 0) > 0;
379
+ const copy = async () => {
380
+ const at = playback.currentMs() ?? 0;
381
+ const link = playback.linkTo(at);
382
+ if (!link) {
383
+ setCopied('A link to this moment could not be made from here.');
384
+ return;
385
+ }
386
+ try {
387
+ await navigator.clipboard.writeText(link);
388
+ setCopied('copied');
389
+ } catch {
390
+ setCopied(link);
391
+ }
392
+ };
393
+ return (
394
+ <Stack gap="xs" component="section" aria-label="Recording moments" data-moments>
395
+ {videos.length > 1 && (
396
+ <NativeSelect
397
+ label="Recording the steps are shown in"
398
+ value={selected.id}
399
+ onChange={(event) => playback.select(event.currentTarget.value)}
400
+ data={videos.map((video) => ({ value: video.id, label: label(video.attachment) }))}
401
+ />
402
+ )}
403
+ {playback.stepCount > 0 && (
404
+ <Text component="div" c="dimmed" data-alignment={timing && !timing.problem ? 'recorded' : 'unavailable'}>
405
+ {timing && !timing.problem
406
+ ? timing.checkStartMs !== undefined
407
+ ? `Steps are placed in ${label(selected.attachment)} from its recorded timing: the check starts ${clock(timing.checkStartMs)} into the recording.`
408
+ : `Steps are placed in ${label(selected.attachment)} only where its producer recorded the moment.`
409
+ : `Steps cannot be placed in ${label(selected.attachment)}: ${timing?.problem ?? 'it carries no timing metadata.'} Atlas does not guess where a step is.`}
410
+ </Text>
411
+ )}
412
+ {paced && playback.stepCount > 0 && (
413
+ <Text component="div" c="dimmed" data-pacing>
414
+ Paced recording
415
+ {timing && timing.pauses.length > 0
416
+ ? `: ${timing.pauses.length === 1 ? 'a pause' : `${timing.pauses.length} pauses`} totalling ${duration(paused)} ${timing.pauses.length === 1 ? 'was' : 'were'} put into the recording${reasons.length ? ` (${reasons.join(', ')})` : ''}. `
417
+ : ': it was slowed for a viewer. '}
418
+ The time between steps includes deliberate pauses; it is pacing, not application latency.
419
+ </Text>
420
+ )}
421
+ <Group gap="xs" wrap="wrap">
422
+ {failed && (
423
+ <Button
424
+ type="button"
425
+ variant="light"
426
+ color="red"
427
+ leftSection={<IconAlertTriangle size={16} aria-hidden="true" />}
428
+ onClick={() => setJump(playback.seekFailure().text)}
429
+ >
430
+ Jump to the failing moment
431
+ </Button>
432
+ )}
433
+ <Button type="button" variant="default" leftSection={<IconLink size={16} aria-hidden="true" />} onClick={() => void copy()}>
434
+ Copy link to this moment
435
+ </Button>
436
+ </Group>
437
+ {copied === 'copied' && (
438
+ <Text component="div" role="status" c="dimmed">
439
+ Link copied. It names this run, this recording and this position, and opens that run even when a newer one exists.
440
+ </Text>
441
+ )}
442
+ {copied !== null && copied !== 'copied' && (
443
+ <TextInput label="Link to this moment" description="Copy it from here." readOnly value={copied} onFocus={(event) => event.currentTarget.select()} />
444
+ )}
445
+ {jump && !notice && (
446
+ <Text component="div" role="status" c="dimmed">
447
+ {jump}
448
+ </Text>
449
+ )}
450
+ {notice && (
451
+ <Alert color="orange" role="status" data-moment-notice>
452
+ {notice.text}
453
+ </Alert>
454
+ )}
455
+ </Stack>
456
+ );
457
+ }
@@ -11,9 +11,12 @@ import type { CheckSlot, HistoryEntry } from '@crvouga/atlas/reports/evidence-vi
11
11
  import type { CheckDetail } from '@crvouga/atlas/reports/evidence-detail';
12
12
  import { effectiveScope, scopeBadges } from '@crvouga/atlas/reports/evidence-scope';
13
13
  import { Badge, Box, Button, Flex, List, Paper, Stack, Text, Title } from '@mantine/core';
14
+ import { IconAlertTriangle } from '@tabler/icons-react';
15
+ import { useState } from 'react';
14
16
 
15
17
  import { ago, duration, shortSha, statusWord } from './format';
16
18
  import { StatusMark } from './marks';
19
+ import { momentOwner, requestFailureMoment } from './moments';
17
20
  import {
18
21
  chronology,
19
22
  failureNote,
@@ -67,6 +70,7 @@ export function OutcomeSummary({
67
70
  const video = point?.videos[0] ?? videosIn(detail?.check)[0];
68
71
  const scope = video ? effectiveScope(video.scope, detail?.check.scope, detail?.inheritedScope) : undefined;
69
72
  const timeline = chronology(recordOf(selected), history);
73
+ const [jumped, setJumped] = useState<string | null>(null);
70
74
  return (
71
75
  <Paper component="section" aria-label="Outcome summary" data-outcome={selected.row.status} withBorder p="md">
72
76
  <Stack gap="sm" miw={0}>
@@ -164,6 +168,29 @@ export function OutcomeSummary({
164
168
  </Text>
165
169
  </Text>
166
170
  )}
171
+ {video && point && (
172
+ <Flex gap="xs" align="center" wrap="wrap">
173
+ <Button
174
+ type="button"
175
+ variant="light"
176
+ color="red"
177
+ leftSection={<IconAlertTriangle size={16} aria-hidden="true" />}
178
+ onClick={() =>
179
+ setJumped(
180
+ requestFailureMoment(momentOwner(selected.layer.id, selected.row.kind, selected.row.id))?.text ??
181
+ 'The recording of this result is not on screen yet. It is in the evidence below.'
182
+ )
183
+ }
184
+ >
185
+ Jump to the failing moment in the recording
186
+ </Button>
187
+ {jumped && (
188
+ <Text span inherit role="status" c="dimmed" data-jump-result>
189
+ {jumped}
190
+ </Text>
191
+ )}
192
+ </Flex>
193
+ )}
167
194
  </Stack>
168
195
  )}
169
196
  {failed && (
@@ -92,12 +92,17 @@ export function failureNote(extensions: Readonly<Record<string, unknown>> | unde
92
92
  * the action). Steps are ordered by their start offset, else by their position. Undefined when no step failed or none
93
93
  * was recorded.
94
94
  */
95
- export function failedStep(steps: readonly Step[] | undefined): FailurePoint | undefined {
95
+ export function failedStepOf(steps: readonly Step[] | undefined): Step | undefined {
96
96
  if (!steps?.length) return undefined;
97
97
  const failedParents = new Set(steps.filter((step) => step.status === 'failed' && step.parentId).map((step) => step.parentId));
98
98
  const order = steps.map((step, index) => ({ step, index }));
99
99
  order.sort((a, b) => (a.step.offsetMs ?? 0) - (b.step.offsetMs ?? 0) || a.index - b.index);
100
- const found = order.find(({ step }) => step.status === 'failed' && !(step.id !== undefined && failedParents.has(step.id)))?.step;
100
+ return order.find(({ step }) => step.status === 'failed' && !(step.id !== undefined && failedParents.has(step.id)))?.step;
101
+ }
102
+
103
+ /** {@link failedStepOf} described as the point a failure happened at: its kind and phase, message, offset and own videos. */
104
+ export function failedStep(steps: readonly Step[] | undefined): FailurePoint | undefined {
105
+ const found = failedStepOf(steps);
101
106
  if (!found) return undefined;
102
107
  return {
103
108
  name: found.name,
@@ -35,7 +35,7 @@ function LatestLink({ children }: { children: string }) {
35
35
  return (
36
36
  <Anchor
37
37
  renderRoot={(props) => (
38
- <Link {...props} to="/" search={patched({ layer: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
38
+ <Link {...props} to="/" search={patched({ layer: undefined, clip: undefined, clipAt: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
39
39
  )}
40
40
  >
41
41
  {children}
@@ -168,7 +168,7 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
168
168
  <Anchor
169
169
  aria-label="Close the test"
170
170
  renderRoot={(props) => (
171
- <Link {...props} to="/" search={patched({ test: undefined, layer: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
171
+ <Link {...props} to="/" search={patched({ test: undefined, layer: undefined, clip: undefined, clipAt: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
172
172
  )}
173
173
  >
174
174
  <IconX size={20} aria-hidden="true" />