@crvouga/atlas 0.2.0 → 0.3.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 (41) hide show
  1. package/CHANGELOG.md +7 -1
  2. package/README.md +8 -2
  3. package/dist/reports/src/export-evidence.js +2 -10
  4. package/dist/reports/src/frames.d.ts +3 -1
  5. package/dist/reports/src/frames.js +21 -0
  6. package/dist/reports/src/publish.js +2 -11
  7. package/dist/reports/src/recording-keys.js +4 -0
  8. package/dist/reports/src/report-types.d.ts +19 -2
  9. package/dist/reports/src/source-entries.js +2 -12
  10. package/package.json +4 -3
  11. package/packages/visualizer/README.md +3 -1
  12. package/packages/visualizer/src/app/AppShell.tsx +11 -12
  13. package/packages/visualizer/src/app/gazetteer.module.css +12 -2
  14. package/packages/visualizer/src/app/gazetteer.tsx +38 -26
  15. package/packages/visualizer/src/components/Header.module.css +20 -4
  16. package/packages/visualizer/src/components/Header.tsx +6 -3
  17. package/packages/visualizer/src/data/model/build.ts +17 -5
  18. package/packages/visualizer/src/data/model/types.ts +10 -1
  19. package/packages/visualizer/src/evidence/api.ts +6 -1
  20. package/packages/visualizer/src/evidence/recording-page.tsx +19 -2
  21. package/packages/visualizer/src/evidence/recording.module.css +4 -8
  22. package/packages/visualizer/src/evidence/recording.tsx +74 -83
  23. package/packages/visualizer/src/evidence/snapshot.tsx +5 -4
  24. package/packages/visualizer/src/evidence/spec-index.tsx +3 -11
  25. package/packages/visualizer/src/find/Finder.module.css +168 -0
  26. package/packages/visualizer/src/find/Finder.tsx +303 -0
  27. package/packages/visualizer/src/find/corpus.test.ts +77 -0
  28. package/packages/visualizer/src/find/corpus.ts +368 -0
  29. package/packages/visualizer/src/find/open.ts +51 -0
  30. package/packages/visualizer/src/find/rank.test.ts +237 -0
  31. package/packages/visualizer/src/find/rank.ts +405 -0
  32. package/packages/visualizer/src/find/semantic.test.ts +62 -0
  33. package/packages/visualizer/src/find/semantic.ts +93 -0
  34. package/packages/visualizer/src/find/semantic.worker.ts +40 -0
  35. package/packages/visualizer/src/looks/Stage.tsx +322 -0
  36. package/packages/visualizer/src/looks/collect.test.ts +158 -0
  37. package/packages/visualizer/src/looks/collect.ts +245 -0
  38. package/packages/visualizer/src/looks/stage.module.css +300 -0
  39. package/packages/visualizer/src/reachable.test.ts +1 -1
  40. package/packages/visualizer/src/views/SidePanel.tsx +39 -49
  41. package/packages/visualizer/src/app/search-query.tsx +0 -14
@@ -0,0 +1,245 @@
1
+ import { testFrames } from '@crvouga/atlas/reports/frames';
2
+ import type { RunReport } from '@crvouga/atlas/reports/report-types';
3
+
4
+ import type { AtlasView, ImageMedia, StateView, TransitionView } from '../data/model';
5
+
6
+ /**
7
+ * What a run captured of the product, in the three shapes any runner can fill.
8
+ * A still, a video, and a timeline of moments are peers: a runner may record one of them or all three.
9
+ */
10
+ export type PictureLook = {
11
+ readonly id: string;
12
+ readonly label: string;
13
+ readonly alt: string;
14
+ readonly src: string;
15
+ readonly thumb: string | null;
16
+ };
17
+
18
+ export type FilmChapter = { readonly atMs: number; readonly label: string };
19
+
20
+ export type FilmLook = {
21
+ readonly id: string;
22
+ readonly label: string;
23
+ readonly src: string;
24
+ readonly poster: string | null;
25
+ readonly captions: string | null;
26
+ readonly durationMs: number | null;
27
+ readonly chapters: readonly FilmChapter[];
28
+ };
29
+
30
+ export type MomentLook = {
31
+ readonly id: string;
32
+ readonly label: string;
33
+ readonly caption: string | null;
34
+ readonly status: 'passed' | 'failed' | null;
35
+ readonly thumb: string | null;
36
+ /** A point in the still, in the still's own pixels, such as where someone tapped. */
37
+ readonly point?: { readonly x: number; readonly y: number } | null;
38
+ readonly pauseMs: number | null;
39
+ };
40
+
41
+ export type Looks = {
42
+ readonly pictures: readonly PictureLook[];
43
+ readonly films: readonly FilmLook[];
44
+ readonly moments: readonly MomentLook[];
45
+ };
46
+
47
+ export const EMPTY_LOOKS: Looks = { pictures: [], films: [], moments: [] };
48
+
49
+ function ready(image: ImageMedia | null | undefined): image is ImageMedia {
50
+ return Boolean(image && image.state === 'available' && (image.full || image.thumb));
51
+ }
52
+
53
+ /** Every still in one capture: the client's own screen, then the other clients at that moment. */
54
+ export function picturesOf(image: ImageMedia | null | undefined, id: string, label: string, alt: string): PictureLook[] {
55
+ if (!ready(image)) return [];
56
+ const src = image.full ?? image.thumb!;
57
+ const shots: PictureLook[] = [
58
+ {
59
+ id,
60
+ label: image.client ? `${label} · ${image.client}` : label,
61
+ alt: image.client ? `${alt}, ${image.client}` : alt,
62
+ src,
63
+ thumb: image.thumb
64
+ }
65
+ ];
66
+ image.also.forEach((extra, index) => {
67
+ const extraSrc = extra.full ?? extra.thumb;
68
+ if (!extraSrc) return;
69
+ const who = extra.client ?? `Screen ${index + 2}`;
70
+ shots.push({ id: `${id}:${who}`, label: `${label} · ${who}`, alt: `${alt}, ${who}`, src: extraSrc, thumb: extra.thumb });
71
+ });
72
+ return shots;
73
+ }
74
+
75
+ const MOMENT_KIND: Record<string, string> = {
76
+ tap: 'Tap',
77
+ type: 'Typing',
78
+ system: 'System',
79
+ screen: 'Screen',
80
+ toast: 'Toast',
81
+ note: 'Note',
82
+ time: 'Time',
83
+ notification: 'Notification'
84
+ };
85
+
86
+ /** The full still of a state. Tap points are recorded in that still's pixels, so the timeline uses it rather than a smaller preview. */
87
+ function stillOf(state: StateView | undefined): string | null {
88
+ if (!state) return null;
89
+ const shot = picturesOf(state.result?.screenshot, state.key, state.name, state.name)[0];
90
+ return shot?.src ?? shot?.thumb ?? null;
91
+ }
92
+
93
+ function unique(shots: readonly PictureLook[]): PictureLook[] {
94
+ const seen = new Set<string>();
95
+ return shots.filter((shot) => {
96
+ if (seen.has(shot.src)) return false;
97
+ seen.add(shot.src);
98
+ return true;
99
+ });
100
+ }
101
+
102
+ /** Stills of one state: the screen, each client, and each path that captured it differently. */
103
+ export function stateLooks(state: StateView): Looks {
104
+ const pictures = unique([
105
+ ...picturesOf(state.result?.screenshot, state.key, state.name, `Screenshot of ${state.name}`),
106
+ ...(state.result?.screenshotsByPath ?? []).flatMap((extra) =>
107
+ picturesOf(extra.image, `${state.key}:${extra.path.id}`, extra.path.name, `Screenshot of ${state.name} along ${extra.path.name}`)
108
+ )
109
+ ]);
110
+ return { pictures, films: [], moments: [] };
111
+ }
112
+
113
+ /** A transition's video, the screens on either side, and the timeline of what the run did. */
114
+ export function eventLooks(view: AtlasView, transition: TransitionView): Looks {
115
+ const from = view.states.get(transition.source);
116
+ const to = transition.target ? view.states.get(transition.target) : undefined;
117
+ const side = (state: StateView | undefined, which: 'Before' | 'After') =>
118
+ state ? stateLooks(state).pictures.map((shot) => ({ ...shot, id: `${which.toLowerCase()}:${shot.id}`, label: `${which} · ${shot.label}`, alt: `${which}: ${shot.alt}` })) : [];
119
+ const pictures = unique([...side(from, 'Before'), ...side(to, 'After')]);
120
+ const entries = transition.result?.timeline ?? [];
121
+ const chapters = entries.map((entry) => ({ atMs: entry.atMs, label: entry.label }));
122
+ const clip = transition.result?.clip;
123
+ const films: FilmLook[] =
124
+ clip?.state === 'available' && clip.src
125
+ ? [{ id: transition.id, label: transition.event, src: clip.src, poster: clip.poster, captions: clip.captions, durationMs: clip.durationMs, chapters }]
126
+ : [];
127
+ let shown = stillOf(from);
128
+ const moments: MomentLook[] = entries.map((entry, index) => {
129
+ if (entry.kind === 'screen') shown = stillOf(to) ?? shown;
130
+ const kind = MOMENT_KIND[entry.kind] ?? entry.kind;
131
+ return {
132
+ id: `${transition.id}:${index}`,
133
+ label: entry.label,
134
+ caption: entry.client ? `${kind} · ${entry.client}` : kind,
135
+ status: null,
136
+ thumb: shown,
137
+ point: entry.x !== undefined && entry.y !== undefined ? { x: entry.x, y: entry.y } : null,
138
+ pauseMs: null
139
+ };
140
+ });
141
+ return { pictures, films, moments };
142
+ }
143
+
144
+ /** The journey as a sequence of landing screens, plus a video for each step that recorded one. */
145
+ export function journeyLooks(view: AtlasView, journeyId: string): Looks {
146
+ const journey = view.journeys.find((item) => item.id === journeyId);
147
+ if (!journey) return EMPTY_LOOKS;
148
+ const pictures: PictureLook[] = [];
149
+ const films: FilmLook[] = [];
150
+ const moments: MomentLook[] = [];
151
+ const seen = new Set<string>();
152
+ journey.steps.forEach((step, index) => {
153
+ const transition = view.transitions.get(step.transitionIds[0] ?? '');
154
+ if (!transition) return;
155
+ const landing = transition.target ? view.states.get(transition.target) : undefined;
156
+ const origin = view.states.get(transition.source);
157
+ const landingShot = landing ? picturesOf(landing.result?.screenshot, landing.key, landing.name, `Screenshot of ${landing.name}`)[0] : undefined;
158
+ const originShot = origin ? picturesOf(origin.result?.screenshot, origin.key, origin.name, `Screenshot of ${origin.name}`)[0] : undefined;
159
+ // The screen they arrived on. When that screen was not captured, the screen they left is still what the step looked like.
160
+ const shot = landingShot ?? originShot;
161
+ if (shot && !seen.has(shot.src)) {
162
+ seen.add(shot.src);
163
+ pictures.push(shot);
164
+ }
165
+ const film = eventLooks(view, transition).films[0];
166
+ if (film) films.push({ ...film, id: `${transition.id}:film`, label: transition.event });
167
+ moments.push({
168
+ id: `${journeyId}:${index}`,
169
+ label: transition.event,
170
+ caption: (landingShot ? landing?.name : origin?.name) ?? null,
171
+ status: transition.status === 'failed' ? 'failed' : transition.status === 'passed' ? 'passed' : null,
172
+ thumb: shot?.src ?? shot?.thumb ?? null,
173
+ point: null,
174
+ pauseMs: film?.durationMs ?? null
175
+ });
176
+ });
177
+ return { pictures, films, moments };
178
+ }
179
+
180
+ function chaptersOf(report: RunReport): FilmChapter[] {
181
+ const chapters: FilmChapter[] = [];
182
+ const visit = (steps: RunReport['steps']) => {
183
+ for (const step of steps) {
184
+ if (step.title) chapters.push({ atMs: Math.max(0, step.startedAtMs - report.meta.startedAtMs), label: step.title });
185
+ visit(step.steps);
186
+ }
187
+ };
188
+ visit(report.steps);
189
+ return chapters.some((chapter) => chapter.atMs > 0) ? chapters : [];
190
+ }
191
+
192
+ /** Stills, videos, and the frame timeline published by any runner's report. */
193
+ export function reportLooks(report: RunReport, fileUrl: (file: string) => string): Looks {
194
+ const frames = testFrames({ report, blobs: {} });
195
+ const pictures: PictureLook[] = [];
196
+ const films: FilmLook[] = [];
197
+ const seen = new Set<string>();
198
+ const visit = (steps: RunReport['steps']) => {
199
+ for (const step of steps) {
200
+ for (const frame of step.frames) {
201
+ for (const blob of frame.snapshots) {
202
+ if (seen.has(blob.file)) continue;
203
+ if (blob.media !== 'png' && blob.media !== 'video') continue;
204
+ seen.add(blob.file);
205
+ const url = fileUrl(blob.file);
206
+ const who = blob.nodeId ?? blob.name;
207
+ if (blob.media === 'png') pictures.push({ id: blob.file, label: `${step.title} · ${who}`, alt: `Recorded ${who}`, src: url, thumb: url });
208
+ else films.push({ id: blob.file, label: who || step.title, src: url, poster: null, captions: null, durationMs: null, chapters: [] });
209
+ }
210
+ }
211
+ visit(step.steps);
212
+ }
213
+ };
214
+ visit(report.steps);
215
+ const chapters = chaptersOf(report);
216
+ for (const artifact of report.artifacts ?? []) {
217
+ if (seen.has(artifact.file)) continue;
218
+ if (artifact.media !== 'png' && artifact.media !== 'video') continue;
219
+ seen.add(artifact.file);
220
+ const url = fileUrl(artifact.file);
221
+ if (artifact.media === 'png') pictures.push({ id: artifact.file, label: artifact.name, alt: artifact.name, src: url, thumb: url });
222
+ else films.push({
223
+ id: artifact.file,
224
+ label: artifact.name,
225
+ src: url,
226
+ poster: artifact.poster ? fileUrl(artifact.poster) : null,
227
+ captions: artifact.captions ? fileUrl(artifact.captions) : null,
228
+ durationMs: null,
229
+ chapters
230
+ });
231
+ }
232
+ const moments: MomentLook[] = frames.map((frame) => {
233
+ const png = frame.frame.snapshots.find((blob) => blob.media === 'png');
234
+ return {
235
+ id: `frame:${frame.index}`,
236
+ label: frame.step.title,
237
+ caption: frame.step.kind,
238
+ status: frame.step.status,
239
+ thumb: png ? fileUrl(png.file) : null,
240
+ point: null,
241
+ pauseMs: frame.frame.durationMs ?? null
242
+ };
243
+ });
244
+ return { pictures, films, moments };
245
+ }
@@ -0,0 +1,300 @@
1
+ .stage {
2
+ --look-h: min(58vh, 620px);
3
+ display: grid;
4
+ gap: 12px;
5
+ min-width: 0;
6
+ margin: 4px 0 8px;
7
+ }
8
+
9
+ .modes {
10
+ display: flex;
11
+ gap: 4px;
12
+ border-bottom: 1px solid var(--line);
13
+ }
14
+
15
+ .modes button {
16
+ padding: 8px 12px;
17
+ border: 0;
18
+ background: none;
19
+ color: var(--ink-3);
20
+ font-size: 0.875rem;
21
+ font-weight: 500;
22
+ cursor: pointer;
23
+ }
24
+
25
+ .modes button[aria-selected='true'] {
26
+ color: var(--ink);
27
+ font-weight: 600;
28
+ box-shadow: inset 0 -2px 0 var(--place);
29
+ }
30
+
31
+ .viewport {
32
+ display: grid;
33
+ justify-items: center;
34
+ align-content: start;
35
+ gap: 10px;
36
+ min-height: 12rem;
37
+ padding: 16px;
38
+ border: 1px solid var(--line);
39
+ border-radius: 16px;
40
+ background:
41
+ linear-gradient(180deg, var(--bg-2) 0%, var(--bg) 100%);
42
+ }
43
+
44
+ .viewport:has(.moment) {
45
+ min-height: 0;
46
+ padding: 28px 20px;
47
+ }
48
+
49
+ .shot,
50
+ .film {
51
+ display: block;
52
+ max-width: 100%;
53
+ max-height: var(--look-h);
54
+ object-fit: contain;
55
+ border-radius: 12px;
56
+ background: #fff;
57
+ box-shadow: var(--shadow-raised);
58
+ }
59
+
60
+ .film {
61
+ /* Phone clips stay phone-shaped. A wide clip uses the width of the plate. --ar is width / height, set once the file is known. */
62
+ --ar: 0.462;
63
+ width: min(100%, calc(var(--look-h) * var(--ar)));
64
+ height: auto;
65
+ background: #0e1216;
66
+ }
67
+
68
+ .shotFrame {
69
+ position: relative;
70
+ display: inline-block;
71
+ max-width: 100%;
72
+ line-height: 0;
73
+ }
74
+
75
+ .mark {
76
+ position: absolute;
77
+ width: 28px;
78
+ height: 28px;
79
+ margin: -14px 0 0 -14px;
80
+ border: 2px solid #fff;
81
+ border-radius: 50%;
82
+ background: color-mix(in srgb, var(--place) 72%, transparent);
83
+ box-shadow:
84
+ 0 0 0 7px color-mix(in srgb, var(--place) 28%, transparent),
85
+ var(--shadow-raised);
86
+ pointer-events: none;
87
+ }
88
+
89
+ .caption {
90
+ margin: 0;
91
+ color: var(--ink-2);
92
+ font-size: 0.8125rem;
93
+ text-align: center;
94
+ }
95
+
96
+ .caption[data-status='failed'] {
97
+ color: var(--failed-ink);
98
+ font-weight: 600;
99
+ }
100
+
101
+ .moment {
102
+ width: min(100%, 36rem);
103
+ margin: 0;
104
+ padding: 20px 22px;
105
+ border-radius: 12px;
106
+ background: var(--bg);
107
+ box-shadow: var(--shadow-raised);
108
+ }
109
+
110
+ .moment p {
111
+ margin: 0 0 6px;
112
+ color: var(--ink-3);
113
+ font-size: 0.75rem;
114
+ font-weight: 600;
115
+ letter-spacing: 0.02em;
116
+ text-transform: uppercase;
117
+ }
118
+
119
+ .moment h3 {
120
+ margin: 0;
121
+ font-size: 1.25rem;
122
+ font-weight: 650;
123
+ letter-spacing: -0.01em;
124
+ line-height: 1.3;
125
+ }
126
+
127
+ .story {
128
+ display: grid;
129
+ justify-items: center;
130
+ gap: 10px;
131
+ width: 100%;
132
+ margin: 0;
133
+ }
134
+
135
+ .storyCaption {
136
+ display: grid;
137
+ justify-items: center;
138
+ gap: 2px;
139
+ margin: 0;
140
+ text-align: center;
141
+ }
142
+
143
+ .storyCaption span {
144
+ color: var(--ink-3);
145
+ font-size: 0.75rem;
146
+ font-weight: 600;
147
+ letter-spacing: 0.04em;
148
+ text-transform: uppercase;
149
+ }
150
+
151
+ .storyCaption strong {
152
+ color: var(--ink);
153
+ font-size: 1rem;
154
+ font-weight: 650;
155
+ letter-spacing: -0.01em;
156
+ }
157
+
158
+ .storyCaption[data-status='failed'] strong {
159
+ color: var(--failed-ink);
160
+ }
161
+
162
+ .moment[data-status='failed'] h3 {
163
+ color: var(--failed-ink);
164
+ }
165
+
166
+ .body {
167
+ width: 100%;
168
+ min-width: 0;
169
+ }
170
+
171
+ .transport {
172
+ display: flex;
173
+ flex-wrap: wrap;
174
+ align-items: center;
175
+ gap: 8px;
176
+ }
177
+
178
+ .control {
179
+ display: inline-flex;
180
+ align-items: center;
181
+ justify-content: center;
182
+ height: 2rem;
183
+ padding: 0 12px;
184
+ border: 1px solid var(--line-2);
185
+ border-radius: var(--pill);
186
+ background: var(--bg);
187
+ color: var(--ink);
188
+ font-size: 0.8125rem;
189
+ font-weight: 600;
190
+ cursor: pointer;
191
+ }
192
+
193
+ .stepper {
194
+ width: 2rem;
195
+ padding: 0;
196
+ }
197
+
198
+ .back {
199
+ transform: rotate(180deg);
200
+ }
201
+
202
+ .control:hover:enabled {
203
+ background: var(--bg-2);
204
+ }
205
+
206
+ .control:disabled {
207
+ opacity: 0.45;
208
+ cursor: default;
209
+ }
210
+
211
+ .scrub {
212
+ flex: 1;
213
+ min-width: 8rem;
214
+ accent-color: var(--place);
215
+ }
216
+
217
+ .position {
218
+ color: var(--ink-3);
219
+ font-size: 0.8125rem;
220
+ white-space: nowrap;
221
+ font-variant-numeric: tabular-nums;
222
+ }
223
+
224
+ .strip,
225
+ .chapters {
226
+ display: flex;
227
+ gap: 8px;
228
+ margin: 0;
229
+ padding: 2px;
230
+ overflow-x: auto;
231
+ list-style: none;
232
+ scroll-snap-type: x mandatory;
233
+ }
234
+
235
+ .strip button,
236
+ .chapters button {
237
+ flex: none;
238
+ padding: 0;
239
+ border: 2px solid transparent;
240
+ border-radius: 10px;
241
+ background: var(--bg);
242
+ cursor: pointer;
243
+ scroll-snap-align: start;
244
+ }
245
+
246
+ .strip button {
247
+ width: 76px;
248
+ overflow: hidden;
249
+ }
250
+
251
+ .strip button[aria-current='true'],
252
+ .chapters button[aria-current='true'] {
253
+ border-color: var(--place);
254
+ }
255
+
256
+ .strip img {
257
+ display: block;
258
+ width: 100%;
259
+ height: 96px;
260
+ object-fit: contain;
261
+ background: var(--bg-2);
262
+ }
263
+
264
+ .chip {
265
+ display: block;
266
+ height: 64px;
267
+ padding: 8px;
268
+ overflow: hidden;
269
+ color: var(--ink-2);
270
+ font-size: 0.6875rem;
271
+ font-weight: 600;
272
+ line-height: 1.3;
273
+ text-align: left;
274
+ }
275
+
276
+ .chapters button {
277
+ max-width: 16rem;
278
+ padding: 6px 10px;
279
+ border-width: 1px;
280
+ border-color: var(--line-2);
281
+ border-radius: var(--pill);
282
+ color: var(--ink-2);
283
+ font-size: 0.75rem;
284
+ text-align: left;
285
+ }
286
+
287
+ .chapters button:hover {
288
+ background: var(--bg-2);
289
+ }
290
+
291
+ .notice,
292
+ .quiet {
293
+ margin: 0;
294
+ color: var(--ink-3);
295
+ font-size: 0.8125rem;
296
+ }
297
+
298
+ .empty {
299
+ width: min(100%, 20rem);
300
+ }
@@ -5,7 +5,7 @@ import { expect, it } from 'bun:test';
5
5
 
6
6
  const SRC = path.dirname(fileURLToPath(import.meta.url));
7
7
  const ENTRY = path.join(SRC, 'main.tsx');
8
- const SPECIFIER = /(?:\bfrom\s*|\bimport\s*\(?\s*|@import\s+(?:url\(\s*)?|\burl\(\s*)['"]([^'"]+)['"]/g;
8
+ const SPECIFIER = /(?:\bfrom\s*|\bimport\s*\(?\s*|@import\s+(?:url\(\s*)?|\burl\(\s*|new URL\(\s*)['"]([^'"]+)['"]/g;
9
9
  const SUFFIXES = ['', '.ts', '.tsx', '.css', '/index.ts', '/index.tsx'];
10
10
 
11
11
  function filesUnder(dir: string): string[] {
@@ -5,8 +5,10 @@ import type { ReactNode } from 'react';
5
5
  import { statusNote, type AtlasView, type StateView, type TransitionView } from '../data/model';
6
6
  import { ChevronIcon } from '../components/icons';
7
7
  import { KIND_LABEL } from '../components/labels';
8
- import { ScreenImage, placeholderReason } from '../components/Screen';
8
+ import { ScreenImage } from '../components/Screen';
9
9
  import { StatusBadge, StatusDot } from '../components/Status';
10
+ import { eventLooks, journeyLooks, stateLooks } from '../looks/collect';
11
+ import { Stage } from '../looks/Stage';
10
12
  import { mapScope } from '../layout/graph';
11
13
  import type { MapSelection } from '../map/ChartMap';
12
14
  import styles from './views.module.css';
@@ -87,9 +89,8 @@ function Questions({ view, ids }: { view: AtlasView; ids: string[] }) {
87
89
  function ScreenDetails({ view, state }: { view: AtlasView; state: StateView }) {
88
90
  const checks = state.result?.checks.length ? state.result.checks : state.specChecks.map((check) => ({ check, passed: null as boolean | null, expected: undefined, actual: undefined }));
89
91
  const note = statusNote(state.status);
90
- const picture = placeholderReason(state.status, state.result?.screenshot ?? null);
91
- const showPictureCaption = picture !== 'not-run' && picture !== 'not-reached';
92
92
  const events = state.result?.businessEvents;
93
+ const screen = state.kind === 'screen' || state.kind === 'final';
93
94
  const incoming = state.incoming.map((id) => view.transitions.get(id)).filter((t): t is TransitionView => Boolean(t) && !t!.carriedBy);
94
95
  const outgoing = state.outgoing.map((id) => view.transitions.get(id)).filter((t): t is TransitionView => Boolean(t));
95
96
  return (
@@ -101,24 +102,30 @@ function ScreenDetails({ view, state }: { view: AtlasView; state: StateView }) {
101
102
  </div>
102
103
  {state.description && <p className={styles.lead}>{state.description}</p>}
103
104
  {note && <p className={styles.statusNote}>{note}</p>}
104
- <div className={styles.plateSplit}>
105
- {(state.kind === 'screen' || state.kind === 'final') && (
106
- <div className={styles.bigShot}>
107
- <ScreenImage key={state.name} state={state} size="full" description={false} lines={state.hints.length > 0} caption={showPictureCaption} />
108
- </div>
109
- )}
110
- <div>
111
- <h3 className={styles.sectionTitle}>Where they go next</h3>
112
- {outgoing.length > 0 ? (
113
- <div className={styles.eventList}>
114
- {outgoing.map((transition) => (
115
- <EventLink key={transition.id} t={transition} view={view} direction="out" />
116
- ))}
105
+ <Stage
106
+ looks={stateLooks(state)}
107
+ prefer="pictures"
108
+ empty={
109
+ screen ? (
110
+ <div className={styles.bigShot}>
111
+ <ScreenImage key={state.name} state={state} size="full" description={false} lines={state.hints.length > 0} />
117
112
  </div>
118
113
  ) : (
119
- <p className={styles.lead}>{state.deadEnd ? `This is where the journey stops: ${state.deadEnd}` : 'Nothing leaves this screen.'}</p>
120
- )}
121
- </div>
114
+ <p className={styles.lead}>This part of the chart has no screen of its own.</p>
115
+ )
116
+ }
117
+ />
118
+ <div>
119
+ <h3 className={styles.sectionTitle}>Where they go next</h3>
120
+ {outgoing.length > 0 ? (
121
+ <div className={styles.eventList}>
122
+ {outgoing.map((transition) => (
123
+ <EventLink key={transition.id} t={transition} view={view} direction="out" />
124
+ ))}
125
+ </div>
126
+ ) : (
127
+ <p className={styles.lead}>{state.deadEnd ? `This is where the journey stops: ${state.deadEnd}` : 'Nothing leaves this screen.'}</p>
128
+ )}
122
129
  </div>
123
130
  {checks.length > 0 && (
124
131
  <Section title="What should be on the screen">
@@ -213,21 +220,6 @@ function ScreenDetails({ view, state }: { view: AtlasView; state: StateView }) {
213
220
  );
214
221
  }
215
222
 
216
- /** The run's recording of the event, with the steps it took as captions when the run wrote them. */
217
- function Clip({ t }: { t: TransitionView }) {
218
- const clip = t.result?.clip;
219
- if (!clip || clip.state === 'missing') return null;
220
- if (clip.state === 'processing' || !clip.src) return <p className={styles.clipPending}>The video of this event is still processing.</p>;
221
- return (
222
- <figure className={styles.clip}>
223
- <video key={clip.src} className={styles.video} src={clip.src} poster={clip.poster ?? undefined} controls preload="metadata" playsInline>
224
- {clip.captions && <track kind="captions" src={clip.captions} srcLang="en" label="Steps" default />}
225
- </video>
226
- <figcaption>What the run did{clip.captions ? ', step by step in the captions' : ''}.</figcaption>
227
- </figure>
228
- );
229
- }
230
-
231
223
  function EventDetails({ view, t }: { view: AtlasView; t: TransitionView }) {
232
224
  const from = view.states.get(t.source);
233
225
  const to = t.target ? view.states.get(t.target) : undefined;
@@ -243,21 +235,12 @@ function EventDetails({ view, t }: { view: AtlasView; t: TransitionView }) {
243
235
  From <strong>{from?.name ?? t.source}</strong> to <strong>{to?.name ?? 'a screen not in the spec'}</strong>.
244
236
  </p>
245
237
  {note && <p className={styles.statusNote}>{note}</p>}
246
- <Clip t={t} />
247
- <div className={styles.beforeAfter}>
248
- {from && (
249
- <figure>
250
- <ScreenImage state={from} size="full" />
251
- <figcaption>Before: {from.name}</figcaption>
252
- </figure>
253
- )}
254
- {to && (
255
- <figure>
256
- <ScreenImage state={to} size="full" />
257
- <figcaption>After: {to.name}</figcaption>
258
- </figure>
259
- )}
260
- </div>
238
+ <Stage
239
+ looks={eventLooks(view, t)}
240
+ prefer="film"
241
+ notice={t.result?.clip.state === 'processing' ? 'The video of this event is still processing.' : null}
242
+ empty={<p className={styles.lead}>This event has no picture, video, or timeline in this run.</p>}
243
+ />
261
244
  <Questions view={view} ids={t.questionIds} />
262
245
  <Developer
263
246
  rows={[
@@ -270,6 +253,12 @@ function EventDetails({ view, t }: { view: AtlasView; t: TransitionView }) {
270
253
  );
271
254
  }
272
255
 
256
+ function JourneyLooks({ view, journeyId }: { view: AtlasView; journeyId: string }) {
257
+ const looks = journeyLooks(view, journeyId);
258
+ if (looks.pictures.length === 0 && looks.films.length === 0 && !looks.moments.some((moment) => moment.thumb)) return null;
259
+ return <Stage looks={looks} prefer="timeline" />;
260
+ }
261
+
273
262
  function JourneyDetails({ view, journeyId, chartId }: { view: AtlasView; journeyId: string; chartId: string }) {
274
263
  const j = view.journeys.find((x) => x.id === journeyId);
275
264
  if (!j) return <p className={styles.lead}>This journey isn’t in the spec.</p>;
@@ -281,6 +270,7 @@ function JourneyDetails({ view, journeyId, chartId }: { view: AtlasView; journey
281
270
  </div>
282
271
  {j.description && <p className={styles.lead}>{j.description}</p>}
283
272
  {!j.replay.ok && <p className={styles.warn}>The chart can’t follow this journey past “{j.replay.failedAt ?? j.replay.missingEnds.join(', ')}”.</p>}
273
+ <JourneyLooks view={view} journeyId={j.id} />
284
274
  <ol className={styles.steps}>
285
275
  {j.steps.map((step, i) => {
286
276
  const t = view.transitions.get(step.transitionIds[0] ?? '');