@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,254 @@
1
+ {
2
+ "$id": "https://github.com/crvouga/atlas/schemas/critique.schema.json",
3
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
4
+ "type": "object",
5
+ "properties": {
6
+ "schema": {
7
+ "type": "integer",
8
+ "exclusiveMinimum": 0,
9
+ "maximum": 9007199254740991,
10
+ "description": "The version of this extension. 1"
11
+ },
12
+ "subject": {
13
+ "type": "object",
14
+ "properties": {
15
+ "entryKey": {
16
+ "type": "string",
17
+ "minLength": 1,
18
+ "description": "The catalog entry that was judged"
19
+ },
20
+ "imageSha256": {
21
+ "type": "string",
22
+ "minLength": 1,
23
+ "description": "The hash of the exact image bytes that were judged. When the entry's image changes, this critique judged an earlier image"
24
+ },
25
+ "state": {
26
+ "description": "The kind of UI state: `screen`, `overlay`, `loading`, `error`, `empty`…",
27
+ "type": "string",
28
+ "minLength": 1
29
+ }
30
+ },
31
+ "required": [
32
+ "entryKey",
33
+ "imageSha256"
34
+ ],
35
+ "additionalProperties": {}
36
+ },
37
+ "critic": {
38
+ "type": "object",
39
+ "properties": {
40
+ "name": {
41
+ "type": "string",
42
+ "minLength": 1
43
+ },
44
+ "version": {
45
+ "type": "string",
46
+ "minLength": 1,
47
+ "description": "Of the critic's own logic. A newer version replaces an older one"
48
+ },
49
+ "kind": {
50
+ "description": "Open vocabulary: `model`, `llm`, `ml`, `heuristic`…",
51
+ "type": "string",
52
+ "minLength": 1
53
+ }
54
+ },
55
+ "required": [
56
+ "name",
57
+ "version"
58
+ ],
59
+ "additionalProperties": {}
60
+ },
61
+ "model": {
62
+ "type": "object",
63
+ "properties": {
64
+ "id": {
65
+ "type": "string",
66
+ "minLength": 1
67
+ },
68
+ "provider": {
69
+ "type": "string"
70
+ }
71
+ },
72
+ "required": [
73
+ "id"
74
+ ],
75
+ "additionalProperties": {}
76
+ },
77
+ "parameters": {
78
+ "description": "Everything else that explains the result: temperature, seed, the SHA-256 of the rubric",
79
+ "type": "object",
80
+ "propertyNames": {
81
+ "type": "string"
82
+ },
83
+ "additionalProperties": {}
84
+ },
85
+ "verdict": {
86
+ "type": "object",
87
+ "properties": {
88
+ "score": {
89
+ "description": "A finite number. `scale` says what it means",
90
+ "type": "number"
91
+ },
92
+ "scale": {
93
+ "description": "For example `0..1, higher is better`",
94
+ "type": "string"
95
+ },
96
+ "label": {
97
+ "description": "Open vocabulary, conventionally `ok` or `needs-polish`",
98
+ "type": "string"
99
+ },
100
+ "confidence": {
101
+ "type": "number",
102
+ "minimum": 0,
103
+ "maximum": 1
104
+ },
105
+ "relative": {
106
+ "type": "object",
107
+ "properties": {
108
+ "stateKind": {
109
+ "type": "string",
110
+ "minLength": 1,
111
+ "description": "The kind of UI state the score is relative to: `screen`, `overlay`, `loading`, `error`, `empty`…"
112
+ },
113
+ "percentile": {
114
+ "description": "Where the score falls among the project's scores of the same state kind, 0..100, higher is better",
115
+ "type": "number",
116
+ "minimum": 0,
117
+ "maximum": 100
118
+ },
119
+ "sampleSize": {
120
+ "description": "How many scores of that state kind the percentile is over",
121
+ "type": "integer",
122
+ "minimum": 0,
123
+ "maximum": 9007199254740991
124
+ },
125
+ "previousScore": {
126
+ "description": "The same entry's score for its previous image, from the same critic",
127
+ "type": "number"
128
+ },
129
+ "delta": {
130
+ "description": "`score` minus `previousScore`",
131
+ "type": "number"
132
+ }
133
+ },
134
+ "required": [
135
+ "stateKind"
136
+ ],
137
+ "additionalProperties": {}
138
+ }
139
+ },
140
+ "additionalProperties": {}
141
+ },
142
+ "findings": {
143
+ "type": "array",
144
+ "items": {
145
+ "type": "object",
146
+ "properties": {
147
+ "id": {
148
+ "description": "Stable within the critique, so a reviewer can mark this finding valid or not",
149
+ "type": "string",
150
+ "minLength": 1
151
+ },
152
+ "category": {
153
+ "description": "Open vocabulary: `spacing`, `contrast`, `alignment`, `overflow`, `hierarchy`…",
154
+ "type": "string",
155
+ "minLength": 1
156
+ },
157
+ "severity": {
158
+ "description": "Open vocabulary, conventionally `info`, `minor`, `major`",
159
+ "type": "string",
160
+ "minLength": 1
161
+ },
162
+ "confidence": {
163
+ "type": "number",
164
+ "minimum": 0,
165
+ "maximum": 1
166
+ },
167
+ "region": {
168
+ "type": "object",
169
+ "properties": {
170
+ "unit": {
171
+ "type": "string",
172
+ "minLength": 1,
173
+ "description": "`px` (pixels of the stored image) or `normalized` (0..1 of its width and height). Open vocabulary"
174
+ },
175
+ "x": {
176
+ "type": "number"
177
+ },
178
+ "y": {
179
+ "type": "number"
180
+ },
181
+ "w": {
182
+ "type": "number",
183
+ "minimum": 0
184
+ },
185
+ "h": {
186
+ "type": "number",
187
+ "minimum": 0
188
+ }
189
+ },
190
+ "required": [
191
+ "unit",
192
+ "x",
193
+ "y",
194
+ "w",
195
+ "h"
196
+ ],
197
+ "additionalProperties": {}
198
+ },
199
+ "rationale": {
200
+ "type": "string"
201
+ },
202
+ "suggestion": {
203
+ "type": "string"
204
+ }
205
+ },
206
+ "additionalProperties": {}
207
+ }
208
+ },
209
+ "failure": {
210
+ "type": "object",
211
+ "properties": {
212
+ "reason": {
213
+ "type": "string",
214
+ "minLength": 1
215
+ },
216
+ "message": {
217
+ "type": "string"
218
+ }
219
+ },
220
+ "required": [
221
+ "reason"
222
+ ],
223
+ "additionalProperties": {}
224
+ },
225
+ "usage": {
226
+ "type": "object",
227
+ "properties": {
228
+ "inputTokens": {
229
+ "type": "number",
230
+ "minimum": 0
231
+ },
232
+ "outputTokens": {
233
+ "type": "number",
234
+ "minimum": 0
235
+ },
236
+ "latencyMs": {
237
+ "type": "number",
238
+ "minimum": 0
239
+ },
240
+ "costUsd": {
241
+ "type": "number",
242
+ "minimum": 0
243
+ }
244
+ },
245
+ "additionalProperties": {}
246
+ }
247
+ },
248
+ "required": [
249
+ "schema",
250
+ "subject",
251
+ "critic"
252
+ ],
253
+ "additionalProperties": {}
254
+ }
@@ -40,10 +40,11 @@
40
40
  "type": "string",
41
41
  "enum": [
42
42
  "read",
43
+ "critic",
43
44
  "publish",
44
45
  "admin"
45
46
  ],
46
- "description": "`read` may look. `publish` may also upload. `admin` may also remove scratch objects"
47
+ "description": "`read` may look. `critic` may also publish critiques (layers of producer `critic.*` whose parts are all of kind `critique`). `publish` may also upload. `admin` may also remove scratch objects"
47
48
  },
48
49
  "token": {
49
50
  "description": "The token. Prefer `tokenEnv`, so the file holds no secret",
@@ -18,6 +18,7 @@
18
18
  "type": "string",
19
19
  "enum": [
20
20
  "read",
21
+ "critic",
21
22
  "publish",
22
23
  "admin"
23
24
  ],
@@ -2,9 +2,9 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Atlas server API",
5
- "version": "1.5.0",
5
+ "version": "1.6.0",
6
6
  "summary": "Take in test results from every environment, keep them, and show everyone the combined report, live.",
7
- "description": "One Atlas node serves multiple projects. Clients select a project using ?project=<slug>, X-Atlas-Project, or /api/projects/<slug>/… . Each project has independent specs, layers, recordings, status, and event streams. Within each project, the newest completed result of every check wins its slot. Unqualified requests address the legacy default project.\n\n**Authentication.** With tokens configured, send `Authorization: Bearer <token>`. Roles: `read` < `publish` < `admin`. Uploading needs a Bearer token; a browser's HTTP Basic sign-in (any user name, the token as the password) is accepted for reading only. A server with no tokens is open and listens on this machine only unless told otherwise.\n\n**Errors** are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem documents (`application/problem+json`) with a `detail` that says what to do. `401` send a token, `403` that token may not, `409` upload what this depends on first or you are changing something immutable, `413` too large, `422` not valid, `503` still starting (retry after `Retry-After`).\n\n**Compatibility.** Within `/api` the API only grows: optional parameters, optional response fields, new events. Ignore what you do not know.\n\nGuides: `/llms.txt`. Source: https://github.com/crvouga/atlas.",
7
+ "description": "One Atlas node serves multiple projects. Clients select a project using ?project=<slug>, X-Atlas-Project, or /api/projects/<slug>/… . Each project has independent specs, layers, recordings, status, and event streams. Within each project, the newest completed result of every check wins its slot. Unqualified requests address the legacy default project.\n\n**Authentication.** With tokens configured, send `Authorization: Bearer <token>`. Roles: `read` < `critic` < `publish` < `admin`. A `critic` token may read, and publish critiques (layers of automated reviewers) and nothing else. Uploading needs a Bearer token; a browser's HTTP Basic sign-in (any user name, the token as the password) is accepted for reading only. A server with no tokens is open and listens on this machine only unless told otherwise.\n\n**Errors** are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem documents (`application/problem+json`) with a `detail` that says what to do. `401` send a token, `403` that token may not, `409` upload what this depends on first or you are changing something immutable, `413` too large, `422` not valid, `503` still starting (retry after `Retry-After`).\n\n**Compatibility.** Within `/api` the API only grows: optional parameters, optional response fields, new events. Ignore what you do not know.\n\nGuides: `/llms.txt`. Source: https://github.com/crvouga/atlas.",
8
8
  "license": {
9
9
  "name": "MIT",
10
10
  "identifier": "MIT"
@@ -2683,8 +2683,8 @@
2683
2683
  "Store protocol"
2684
2684
  ],
2685
2685
  "summary": "Upload an object",
2686
- "description": "The write half of the `ObjectStore` interface. The server checks what it can from the bytes in hand and says no to what would corrupt the report for everyone:\n\n- a **layer manifest** (`layers/manifests/<id>.json`) must be a valid layer whose content hashes to `<id>`, and its blobs must already be there (`409` otherwise);\n- a **blob** (`layers/blobs/<sha256>.gz`) must be gzip of content whose SHA-256 is its name; a **spec file** likewise;\n- a **catalog entry** (`…/index/…`) must name something already uploaded (`409` otherwise): it is what makes a layer visible, so it is written last;\n- a **run manifest** must have passed its privacy scan;\n- keys are immutable: the same bytes again are `200` (a retry), other bytes under the same key are `409`.\n\nUpload in dependency order: blobs, then the manifest, then the catalog entry. `atlas report publish`, `atlas report stream` and `httpStore` do. Needs a `Bearer` token of role `publish` or `admin`; a browser's sign-in box is refused.\n\nRole needed: `publish`.",
2687
- "x-atlas-role": "publish",
2686
+ "description": "The write half of the `ObjectStore` interface. The server checks what it can from the bytes in hand and says no to what would corrupt the report for everyone:\n\n- a **layer manifest** (`layers/manifests/<id>.json`) must be a valid layer whose content hashes to `<id>`, and its blobs must already be there (`409` otherwise);\n- a **blob** (`layers/blobs/<sha256>.gz`) must be gzip of content whose SHA-256 is its name; a **spec file** likewise;\n- a **catalog entry** (`…/index/…`) must name something already uploaded (`409` otherwise): it is what makes a layer visible, so it is written last;\n- a **run manifest** must have passed its privacy scan;\n- keys are immutable: the same bytes again are `200` (a retry), other bytes under the same key are `409`.\n\nUpload in dependency order: blobs, then the manifest, then the catalog entry. `atlas report publish`, `atlas report stream` and `httpStore` do. Needs a `Bearer` token of role `publish` or `admin`; a browser's sign-in box is refused.\n\nA token of role `critic` may upload only what a critic publishes: blobs, and layers (and their catalog entries) whose `producer` is `critic.<name>` and whose parts are all of kind `critique`. Anything else is `403` with the rule it broke, and nothing is stored.\n\nRole needed: `critic`.",
2687
+ "x-atlas-role": "critic",
2688
2688
  "security": [
2689
2689
  {
2690
2690
  "bearer": []
@@ -3029,8 +3029,8 @@
3029
3029
  "Store protocol"
3030
3030
  ],
3031
3031
  "summary": "Upload an object",
3032
- "description": "The write half of the `ObjectStore` interface. The server checks what it can from the bytes in hand and says no to what would corrupt the report for everyone:\n\n- a **layer manifest** (`layers/manifests/<id>.json`) must be a valid layer whose content hashes to `<id>`, and its blobs must already be there (`409` otherwise);\n- a **blob** (`layers/blobs/<sha256>.gz`) must be gzip of content whose SHA-256 is its name; a **spec file** likewise;\n- a **catalog entry** (`…/index/…`) must name something already uploaded (`409` otherwise): it is what makes a layer visible, so it is written last;\n- a **run manifest** must have passed its privacy scan;\n- keys are immutable: the same bytes again are `200` (a retry), other bytes under the same key are `409`.\n\nUpload in dependency order: blobs, then the manifest, then the catalog entry. `atlas report publish`, `atlas report stream` and `httpStore` do. Needs a `Bearer` token of role `publish` or `admin`; a browser's sign-in box is refused.\n\nScoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.\n\nRole needed: `publish`.",
3033
- "x-atlas-role": "publish",
3032
+ "description": "The write half of the `ObjectStore` interface. The server checks what it can from the bytes in hand and says no to what would corrupt the report for everyone:\n\n- a **layer manifest** (`layers/manifests/<id>.json`) must be a valid layer whose content hashes to `<id>`, and its blobs must already be there (`409` otherwise);\n- a **blob** (`layers/blobs/<sha256>.gz`) must be gzip of content whose SHA-256 is its name; a **spec file** likewise;\n- a **catalog entry** (`…/index/…`) must name something already uploaded (`409` otherwise): it is what makes a layer visible, so it is written last;\n- a **run manifest** must have passed its privacy scan;\n- keys are immutable: the same bytes again are `200` (a retry), other bytes under the same key are `409`.\n\nUpload in dependency order: blobs, then the manifest, then the catalog entry. `atlas report publish`, `atlas report stream` and `httpStore` do. Needs a `Bearer` token of role `publish` or `admin`; a browser's sign-in box is refused.\n\nA token of role `critic` may upload only what a critic publishes: blobs, and layers (and their catalog entries) whose `producer` is `critic.<name>` and whose parts are all of kind `critique`. Anything else is `403` with the rule it broke, and nothing is stored.\n\nScoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.\n\nRole needed: `critic`.",
3033
+ "x-atlas-role": "critic",
3034
3034
  "security": [
3035
3035
  {
3036
3036
  "bearer": []
@@ -10011,6 +10011,7 @@
10011
10011
  "type": "string",
10012
10012
  "enum": [
10013
10013
  "read",
10014
+ "critic",
10014
10015
  "publish",
10015
10016
  "admin"
10016
10017
  ],
@@ -30,6 +30,9 @@ const SpecSearch = RootSearch.extend({
30
30
  spec: z.string().min(1).optional().catch(undefined),
31
31
  check: z.string().min(1).optional().catch(undefined),
32
32
  layer: z.string().min(1).optional().catch(undefined),
33
+ /** A shared moment: the exact recording (an `attachmentIdentity`) of that layer, and the position in it in milliseconds. */
34
+ clip: z.coerce.string().min(1).optional().catch(undefined),
35
+ clipAt: z.coerce.number().nonnegative().optional().catch(undefined),
33
36
  view: z.enum(['recording']).optional().catch(undefined),
34
37
  moment: z.coerce.number().int().positive().optional().catch(undefined),
35
38
  /** The release evidence is read against: its build, commit and platform, and the evidence filter (see `evidence/applicability.ts`). A build such as `90` is read from a typed link as a number: coerced back to its text. */
@@ -1,5 +1,5 @@
1
1
  import type { CheckDetail, EvidenceHolder } from '@crvouga/atlas/reports/evidence-detail';
2
- import { isVideoAttachment, roleOfType } from '@crvouga/atlas/reports/evidence-detail';
2
+ import { attachmentIdentity, isVideoAttachment, roleOfType } from '@crvouga/atlas/reports/evidence-detail';
3
3
  import { effectiveScope } from '@crvouga/atlas/reports/evidence-scope';
4
4
  import type { LayerHeader, LayerRow } from '@crvouga/atlas/reports/evidence-types';
5
5
  import { contentTypeOf } from '@crvouga/atlas/reports/media-types';
@@ -12,6 +12,8 @@ import { reportAssetUrl, reportJson, reportText } from './api';
12
12
  import { ScopeNotice, ScopeProvider, useScope } from './evidence-scope';
13
13
  import { duration, plural, safeUri } from './format';
14
14
  import { StatusMark } from './marks';
15
+ import { MomentBar, MomentPlayback, momentOwner, SeekStep, useIsPlaying, useMomentVideo, useRequestedMoment } from './moments';
16
+ import { videosIn } from './outcome';
15
17
  /**
16
18
  * How one kind of attachment is shown. The first renderer that matches wins, so a new kind of artifact (a
17
19
  * flame graph, a HAR, a coverage map) is one more entry, and an attachment nobody has a renderer for is still
@@ -38,6 +40,8 @@ const label = (attachment: Attachment) => attachment.description ?? attachment.n
38
40
  /** A video that plays inline; when the file cannot be loaded or this browser cannot play it, it says so and offers the file, instead of an empty player. */
39
41
  function VideoPlayer({ attachment, url }: { attachment: Attachment; url: string }) {
40
42
  const [failed, setFailed] = useState(false);
43
+ // Inside a result's playback the player is the one its steps seek and that marks the step playing.
44
+ const moments = useMomentVideo(attachment);
41
45
  if (failed)
42
46
  return (
43
47
  <Alert role="alert" color="orange" title="This video cannot be played">
@@ -50,7 +54,19 @@ function VideoPlayer({ attachment, url }: { attachment: Attachment; url: string
50
54
  </Text>
51
55
  </Alert>
52
56
  );
53
- return <Box src={url} controls preload="metadata" aria-label={label(attachment)} component="video" maw="100%" w="100%" onError={() => setFailed(true)} />;
57
+ return (
58
+ <Box
59
+ src={url}
60
+ controls
61
+ preload="metadata"
62
+ aria-label={label(attachment)}
63
+ component="video"
64
+ maw="100%"
65
+ w="100%"
66
+ onError={() => setFailed(true)}
67
+ {...moments}
68
+ />
69
+ );
54
70
  }
55
71
  function TextValue({ attachment, path }: { attachment: Attachment; path: string | null }) {
56
72
  const stored = useQuery({
@@ -261,37 +277,11 @@ export function Timeline({ steps, layer }: { steps: readonly Step[]; layer: stri
261
277
  }, [steps]);
262
278
  const render = (parent: string | null): ReactNode => (
263
279
  <List spacing="xs" listStyleType="none" type="ordered">
264
- {(tree.get(parent) ?? []).map((step, index) => {
265
- const status = STEP_STATUS[(step.status ?? '').toLowerCase()];
266
- return (
267
- <List.Item data-evidence-step key={`${step.id ?? step.name}:${index}`} data-status={status ?? step.status ?? 'none'}>
268
- <Flex gap="xs" align="center" wrap="wrap">
269
- <StatusMark status={status ?? 'none'} size={14} />
270
- <Text span inherit>
271
- {step.name}
272
- </Text>
273
- {step.kind && (
274
- <Text span inherit>
275
- {step.kind}
276
- </Text>
277
- )}
278
- {step.status && !status && (
279
- <Text span inherit>
280
- {step.status}
281
- </Text>
282
- )}
283
- <Box flex={1} />
284
- <Text span inherit c="dimmed">
285
- {[step.offsetMs !== undefined ? `+${duration(step.offsetMs)}` : '', duration(step.durationMs)].filter(Boolean).join(' · ')}
286
- </Text>
287
- </Flex>
288
- {step.detail && <Text component="div">{step.detail}</Text>}
289
- {step.annotations && <Notes notes={step.annotations} />}
290
- {step.attachments && <Attachments attachments={step.attachments} layer={layer} />}
291
- {step.id && tree.has(step.id) && render(step.id)}
292
- </List.Item>
293
- );
294
- })}
280
+ {(tree.get(parent) ?? []).map((step, index) => (
281
+ <TimelineStep key={`${step.id ?? step.name}:${index}`} step={step} layer={layer}>
282
+ {step.id && tree.has(step.id) && render(step.id)}
283
+ </TimelineStep>
284
+ ))}
295
285
  </List>
296
286
  );
297
287
  return (
@@ -299,10 +289,52 @@ export function Timeline({ steps, layer }: { steps: readonly Step[]; layer: stri
299
289
  <Title order={4} mb="sm">
300
290
  {plural(steps.length, 'step', 'steps')}
301
291
  </Title>
292
+ <MomentBar />
302
293
  {render(null)}
303
294
  </Box>
304
295
  );
305
296
  }
297
+ /** One step of the timeline: its outcome and times, a control to show it in the recording when there is one, and what it carries. */
298
+ function TimelineStep({ step, layer, children }: { step: Step; layer: string; children: ReactNode }) {
299
+ const status = STEP_STATUS[(step.status ?? '').toLowerCase()];
300
+ const playing = useIsPlaying(step);
301
+ return (
302
+ <List.Item
303
+ data-evidence-step
304
+ data-status={status ?? step.status ?? 'none'}
305
+ data-playing={playing || undefined}
306
+ aria-current={playing ? 'step' : undefined}
307
+ {...(playing ? { bg: 'blue.0' } : {})}
308
+ >
309
+ <Flex gap="xs" align="center" wrap="wrap">
310
+ <StatusMark status={status ?? 'none'} size={14} />
311
+ <Text span inherit>
312
+ {step.name}
313
+ </Text>
314
+ {step.kind && (
315
+ <Text span inherit>
316
+ {step.kind}
317
+ </Text>
318
+ )}
319
+ {step.status && !status && (
320
+ <Text span inherit>
321
+ {step.status}
322
+ </Text>
323
+ )}
324
+ {playing && <Badge variant="light">Playing now</Badge>}
325
+ <Box flex={1} />
326
+ <SeekStep step={step} />
327
+ <Text span inherit c="dimmed">
328
+ {[step.offsetMs !== undefined ? `+${duration(step.offsetMs)}` : '', duration(step.durationMs)].filter(Boolean).join(' · ')}
329
+ </Text>
330
+ </Flex>
331
+ {step.detail && <Text component="div">{step.detail}</Text>}
332
+ {step.annotations && <Notes notes={step.annotations} />}
333
+ {step.attachments && <Attachments attachments={step.attachments} layer={layer} />}
334
+ {children}
335
+ </List.Item>
336
+ );
337
+ }
306
338
  export function Notes({ notes }: { notes: readonly Annotation[] }) {
307
339
  if (!notes.length) return null;
308
340
  return (
@@ -333,6 +365,7 @@ export function Notes({ notes }: { notes: readonly Annotation[] }) {
333
365
  </List>
334
366
  );
335
367
  }
368
+ const NO_FILES: readonly Attachment[] = [];
336
369
  function Tries({ attempts, layer }: { attempts: readonly Attempt[]; layer: string }) {
337
370
  if (!attempts.length) return null;
338
371
  return (
@@ -355,7 +388,11 @@ function Tries({ attempts, layer }: { attempts: readonly Attempt[]; layer: strin
355
388
  </Flex>
356
389
  {attempt.detail && <Text component="div">{attempt.detail}</Text>}
357
390
  <ScopeProvider scope={attempt.scope}>
358
- {attempt.attachments && <Attachments attachments={attempt.attachments} layer={layer} />}
391
+ {/* A try is placed in its own recording with that recording's own timing, never in another try's. */}
392
+ <MomentPlayback steps={attempt.steps} attachments={attempt.attachments ?? NO_FILES} layer={layer}>
393
+ {attempt.steps && attempt.steps.length > 0 ? <Timeline steps={attempt.steps} layer={layer} /> : <MomentBar />}
394
+ {attempt.attachments && <Attachments attachments={attempt.attachments} layer={layer} />}
395
+ </MomentPlayback>
359
396
  </ScopeProvider>
360
397
  </List.Item>
361
398
  ))}
@@ -413,15 +450,17 @@ function Facts({ holder }: { holder: EvidenceHolder }) {
413
450
  </Stack>
414
451
  );
415
452
  }
416
- function Evidence({ holder, layer, steps }: { holder: EvidenceHolder; layer: string; steps?: boolean }) {
453
+ function Evidence({ holder, layer, steps = false, owner }: { holder: EvidenceHolder; layer: string; steps?: boolean; owner?: string }) {
417
454
  return (
418
455
  <ScopeProvider scope={holder.scope}>
419
- <Facts holder={holder} />
420
- {holder.annotations && <Notes notes={holder.annotations} />}
421
- {steps && holder.steps && holder.steps.length > 0 && <Timeline steps={holder.steps} layer={layer} />}
422
- {holder.attempts && holder.attempts.length > 1 && <Tries attempts={holder.attempts} layer={layer} />}
423
- {holder.attachments && <Attachments attachments={holder.attachments} layer={layer} />}
424
- {holder.extensions && <Raw extensions={holder.extensions} />}
456
+ <MomentPlayback steps={steps ? holder.steps : undefined} attachments={holder.attachments ?? NO_FILES} layer={layer} primary={steps} owner={owner}>
457
+ <Facts holder={holder} />
458
+ {holder.annotations && <Notes notes={holder.annotations} />}
459
+ {steps && holder.steps && holder.steps.length > 0 ? <Timeline steps={holder.steps} layer={layer} /> : <MomentBar />}
460
+ {holder.attempts && holder.attempts.length > 1 && <Tries attempts={holder.attempts} layer={layer} />}
461
+ {holder.attachments && <Attachments attachments={holder.attachments} layer={layer} />}
462
+ {holder.extensions && <Raw extensions={holder.extensions} />}
463
+ </MomentPlayback>
425
464
  </ScopeProvider>
426
465
  );
427
466
  }
@@ -444,6 +483,7 @@ export function useCheckDetail(layer: LayerHeader, row: LayerRow, enabled = true
444
483
  export function ResultEvidence({ layer, row }: { layer: LayerHeader; row: LayerRow }) {
445
484
  const wanted = row.evidence !== undefined || layer.evidence !== undefined;
446
485
  const detail = useCheckDetail(layer, row);
486
+ const requested = useRequestedMoment();
447
487
  if (!wanted) return null;
448
488
  if (detail.isLoading)
449
489
  return (
@@ -466,13 +506,19 @@ export function ResultEvidence({ layer, row }: { layer: LayerHeader; row: LayerR
466
506
  <ArtifactTagsProvider layer={layer.id} kind={row.kind} check={row.id} holders={[check, run]}>
467
507
  <Stack aria-label="Evidence" gap="sm" miw={0}>
468
508
  <TagFilterBar holders={[check, run]} />
509
+ {requested.clip !== undefined && requested.layer === layer.id && ![...videosIn(check), ...videosIn(run)].some((video) => attachmentIdentity(video) === requested.clip) && (
510
+ <Alert role="status" color="orange" title="The recording this link names is not in this result" data-moment-missing>
511
+ The link names a recording that this run did not record for this check. Nothing is played in its place, and no other run is
512
+ substituted.
513
+ </Alert>
514
+ )}
469
515
  {declared && (
470
516
  <Box aria-label="Scope of this result" component="section">
471
517
  <ScopeNotice scope={declared} name={row.title} />
472
518
  </Box>
473
519
  )}
474
520
  <ScopeProvider scope={inheritedScope}>
475
- <Evidence holder={check} layer={layer.id} steps />
521
+ <Evidence holder={check} layer={layer.id} steps owner={momentOwner(layer.id, row.kind, row.id)} />
476
522
  </ScopeProvider>
477
523
  {hasRun && (
478
524
  <Box aria-label="From the run" component="section" mb="lg">