@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
package/CHANGELOG.md CHANGED
@@ -1,6 +1,12 @@
1
1
  # Changelog: @crvouga/atlas
2
2
 
3
- ## 0.2.0 (2026-10-13)
3
+ ## 0.3.0 (2026-10-13)
4
+
5
+ ### Features
6
+
7
+ - run the todo specs, find them in plain language, and play their evidence (#5) ([caa0df8](https://github.com/crvouga/atlas/commit/caa0df8fec96aa8e910dae53afc59ae705707359))
8
+
9
+ ## 0.2.0 (2026-10-12)
4
10
 
5
11
  ### Features
6
12
 
package/README.md CHANGED
@@ -81,6 +81,10 @@ served by a small Node server) specified as two charts:
81
81
  - `specs/getting-started.machine.yaml`, the child chart in XState YAML. Its two final states hand
82
82
  back to the parent through `meta.doneEvent`.
83
83
  - `specs/journeys.yaml`, four curated journeys.
84
+ - `specs/getting-started.feature` and `specs/working-the-list.feature`, the same stories in Gherkin.
85
+ - `specs/todo.qnt`, the list, its sync, and its actions as a Quint model.
86
+
87
+ The visualizer lists the features under Gherkin and the model under Quint.
84
88
 
85
89
  It has every kind of event: user events (taps and typing), a **system** event ("Sync fails", which
86
90
  the test triggers through a real HTTP endpoint on the example server), a **time** event ("Retry
@@ -98,7 +102,7 @@ bunx playwright install chromium
98
102
  bun run example:lint # pure, reachable, no undocumented dead ends, journeys replay, everything implemented
99
103
  bun run example:plan # 4 journeys + 1 generated path cover all 14 transitions
100
104
  bun run example:fast # run every path quickly
101
- bun run example:run # showcase mode: paced, with a touch overlay, screenshots and a clip per transition
105
+ bun run example:run # statechart showcase, then the Gherkin features and the Quint model
102
106
  bun test examples # the same spec against an in-memory model, as a test suite
103
107
 
104
108
  bun run example:clients # clients/: one chart across a phone, a desktop and the sync server at once
@@ -110,7 +114,7 @@ visualizer updates when a run finishes.
110
114
 
111
115
  ```sh
112
116
  bun run atlas:run # example:clients, then example:run: every chart of the example, in showcase mode
113
- bun run atlas:viz # the example's specs and those runs, in the visualizer
117
+ bun run atlas:viz # the example's specs, chart runs, Gherkin features, and Quint model
114
118
  ```
115
119
 
116
120
  Each config writes its own run and the visualizer shows one run at a time: the latest is the
@@ -124,6 +128,8 @@ Transitions 14/14 passed, 0 failed, 0 not reached
124
128
  Manifest: examples/todo/atlas-runs/<run-id>/manifest.json
125
129
  ```
126
130
 
131
+ `example:run` then runs the Gherkin features in a browser and the Quint model. `atlas:viz` shows those results on the scenario and trace rows.
132
+
127
133
  `bun test examples` runs [`model.test.ts`](examples/todo/model.test.ts): the same charts and journeys
128
134
  against `TodoModel`, a plain class, through `functionDriver` and `describeAtlas`. Only the driver
129
135
  and the implementation differ, which is the point: the spec belongs to the product, not to one
@@ -2,6 +2,7 @@ import { eachLimited } from './completion-order.js';
2
2
  import { recordingPaths } from './evidence-types.js';
3
3
  import { distinctHeaders } from './layer-merge.js';
4
4
  import { writeUtf8 } from './object-io.js';
5
+ import { capturedFiles } from './frames.js';
5
6
  export function evidenceFile(path) {
6
7
  const url = new URL(path || '/', 'http://atlas');
7
8
  if (url.pathname === '/')
@@ -76,19 +77,10 @@ export async function exportEvidence(args) {
76
77
  const raw = await copy(paths.report);
77
78
  if (!raw)
78
79
  continue;
79
- await eachLimited([...frameFiles(JSON.parse(raw).steps)], 4, file => copy(paths.file(file)).then(() => { }));
80
+ await eachLimited(capturedFiles(JSON.parse(raw)), 4, file => copy(paths.file(file)).then(() => { }));
80
81
  }
81
82
  });
82
83
  await writeUtf8(args.target, evidenceFile(path), JSON.stringify(exported), 'application/json');
83
84
  }
84
85
  return { revisions: revisions.size, layers, missing };
85
86
  }
86
- function frameFiles(steps, files = new Set()) {
87
- for (const step of steps) {
88
- for (const frame of step.frames)
89
- for (const blob of frame.snapshots)
90
- files.add(blob.file);
91
- frameFiles(step.steps, files);
92
- }
93
- return files;
94
- }
@@ -1,5 +1,5 @@
1
1
  import type { HtmlReportTest } from './recording-bundle.js';
2
- import type { ReportFrame, ReportSnapshotBlob, ReportStep } from './report-types.js';
2
+ import type { ReportFrame, ReportSnapshotBlob, ReportStep, RunReport } from './report-types.js';
3
3
  import type { Route } from './hash.js';
4
4
  export type FlatFrame = {
5
5
  step: ReportStep;
@@ -28,6 +28,8 @@ export declare function testFrames(test: HtmlReportTest): FlatFrame[];
28
28
  /** Return UI snapshots in one stable actor order, including empty slots. */
29
29
  export declare function orderedUiBlobs(frame: FlatFrame | null, test: HtmlReportTest): OrderedUiBlob[];
30
30
  export declare function selectedFrame(frames: readonly FlatFrame[], route: Route): FlatFrame | null;
31
+ /** Every file a report names: step snapshots, and files attached to the test as a whole. */
32
+ export declare function capturedFiles(report: Pick<RunReport, 'steps' | 'artifacts'>): string[];
31
33
  export declare function isUi(b: ReportSnapshotBlob): boolean;
32
34
  export declare function uiBlobs(frame: FlatFrame | null): ReportSnapshotBlob[];
33
35
  export declare function stageBlobs(frame: FlatFrame | null): ReportSnapshotBlob[];
@@ -137,6 +137,27 @@ export function selectedFrame(frames, route) {
137
137
  }
138
138
  return frames[frames.length - 1] ?? null;
139
139
  }
140
+ /** Every file a report names: step snapshots, and files attached to the test as a whole. */
141
+ export function capturedFiles(report) {
142
+ const files = new Set();
143
+ const visit = (steps) => {
144
+ for (const step of steps) {
145
+ for (const frame of step.frames)
146
+ for (const blob of frame.snapshots)
147
+ files.add(blob.file);
148
+ visit(step.steps);
149
+ }
150
+ };
151
+ visit(report.steps);
152
+ for (const artifact of report.artifacts ?? []) {
153
+ files.add(artifact.file);
154
+ if (artifact.poster)
155
+ files.add(artifact.poster);
156
+ if (artifact.captions)
157
+ files.add(artifact.captions);
158
+ }
159
+ return [...files];
160
+ }
140
161
  export function isUi(b) {
141
162
  return b.media === 'html' || b.media === 'png';
142
163
  }
@@ -3,6 +3,7 @@ import { performancePart } from './performance-part.js';
3
3
  import { applyJunitOutcomes, parseJunit, } from './junit.js';
4
4
  import { testFrameKey, testPrefix, testReportKey, } from './recording-keys.js';
5
5
  import { suiteStatus } from './status.js';
6
+ import { capturedFiles } from './frames.js';
6
7
  import { readUtf8, writeUtf8 } from './object-io.js';
7
8
  import { bundleIndexKey, writeBundle } from './bundle.js';
8
9
  import { eachLimited } from './completion-order.js';
@@ -67,20 +68,10 @@ async function readTests(bucket, runId) {
67
68
  ...applyJunitOutcomes({ runId, status: 'passed', updatedAtMs: 0, tests }, parseJunit(junit)).tests,
68
69
  ];
69
70
  }
70
- function frameFiles(steps, out) {
71
- for (const step of steps) {
72
- for (const frame of step.frames) {
73
- for (const blob of frame.snapshots)
74
- out.add(blob.file);
75
- }
76
- frameFiles(step.steps, out);
77
- }
78
- }
79
71
  async function publishSnapshots(store, args, tests, contentPrefix) {
80
72
  const runId = args.runId ?? SPEC_RUN_ID;
81
73
  await eachLimited(tests, 8, async ({ report }) => {
82
- const files = new Set();
83
- frameFiles(report.steps, files);
74
+ const files = new Set(capturedFiles(report));
84
75
  await publishSnapshotBundle({
85
76
  local: args.local,
86
77
  remote: store,
@@ -28,6 +28,8 @@ export function mediaExtension(media) {
28
28
  return 'txt';
29
29
  case 'bytes':
30
30
  return 'bin';
31
+ case 'video':
32
+ return 'mp4';
31
33
  default:
32
34
  return 'bin';
33
35
  }
@@ -44,6 +46,8 @@ export function contentTypeForMedia(media) {
44
46
  return 'text/plain; charset=utf-8';
45
47
  case 'bytes':
46
48
  return 'application/octet-stream';
49
+ case 'video':
50
+ return 'video/mp4';
47
51
  default:
48
52
  return 'application/octet-stream';
49
53
  }
@@ -18,8 +18,12 @@ export type ReportPerformanceOptions = {
18
18
  /** Diagnostic threshold, not a portable CPU or paint-time assertion. */
19
19
  readonly slowStepMs: number;
20
20
  };
21
- /** How a captured value is encoded on disk and rendered in the report. */
22
- export type SnapshotMedia = 'json' | 'html' | 'png' | 'text' | 'bytes';
21
+ /**
22
+ * How a captured value is encoded on disk and rendered in the report.
23
+ * `png` is a still, `html` is a page snapshot, and `video` is an mp4 of the moment or the whole test.
24
+ * A runner may record any mix. The viewer treats a still, a video, and a timeline of frames as the same kind of evidence.
25
+ */
26
+ export type SnapshotMedia = 'json' | 'html' | 'png' | 'text' | 'bytes' | 'video';
23
27
  export type ReportStepStatus = 'passed' | 'failed';
24
28
  /** Test outcome after the harness (e.g. the test runner) has weighed in. */
25
29
  export type ReportTestStatus = 'passed' | 'failed' | 'skipped';
@@ -102,10 +106,23 @@ export type RunReportMeta = {
102
106
  readonly error?: string;
103
107
  readonly errorStack?: string;
104
108
  };
109
+ /**
110
+ * A file attached to the whole test. A runner that can capture each step puts stills and page
111
+ * snapshots on `steps[].frames` instead, and the viewer builds a timeline from those. This list
112
+ * is for what the runner can attach when it cannot: a video of the test, a few screenshots, a log.
113
+ */
114
+ export type ReportArtifact = {
115
+ readonly name: string;
116
+ readonly media: SnapshotMedia;
117
+ readonly file: string;
118
+ readonly poster?: string;
119
+ readonly captions?: string;
120
+ };
105
121
  /** In-memory / persisted timeline for one test run. */
106
122
  export type RunReport = {
107
123
  readonly meta: RunReportMeta;
108
124
  readonly steps: readonly ReportStep[];
125
+ readonly artifacts?: readonly ReportArtifact[];
109
126
  };
110
127
  export type SuiteTestSummary = {
111
128
  readonly testId: string;
@@ -1,15 +1,7 @@
1
1
  import { testFrameKey } from './recording-keys.js';
2
+ import { capturedFiles } from './frames.js';
2
3
  import { remoteTestFileKey, remoteTestReportKey } from './keys.js';
3
4
  const JSON_TYPE = 'application/json; charset=utf-8';
4
- function frameFiles(steps, out) {
5
- for (const step of steps) {
6
- for (const frame of step.frames) {
7
- for (const blob of frame.snapshots)
8
- out.add(blob.file);
9
- }
10
- frameFiles(step.steps, out);
11
- }
12
- }
13
5
  export async function* sourceEntries(args, source, tests, runId) {
14
6
  for (const { report } of [...tests].sort((a, b) => a.report.meta.testId < b.report.meta.testId ? -1 : 1)) {
15
7
  const { testId } = report.meta;
@@ -18,9 +10,7 @@ export async function* sourceEntries(args, source, tests, runId) {
18
10
  bytes: new TextEncoder().encode(JSON.stringify(report)),
19
11
  contentType: JSON_TYPE,
20
12
  };
21
- const files = new Set();
22
- frameFiles(report.steps, files);
23
- for (const file of [...files].sort()) {
13
+ for (const file of capturedFiles(report).sort()) {
24
14
  const obj = await args.local.get(testFrameKey(runId, testId, file));
25
15
  if (obj === null)
26
16
  throw new Error(`Missing report snapshot: ${file}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crvouga/atlas",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Atlas: executable statecharts. Spec a product's flows as statecharts, run them against the real app with any driver, and browse what happened.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -115,11 +115,11 @@
115
115
  "example:lint": "bun --cwd examples/todo ../../packages/core/bin/atlas.mjs lint --require-implementations",
116
116
  "example:plan": "bun --cwd examples/todo ../../packages/core/bin/atlas.mjs plan",
117
117
  "example:fast": "bun --cwd examples/todo ../../packages/core/bin/atlas.mjs run --mode fast",
118
- "example:run": "bun --cwd examples/todo ../../packages/core/bin/atlas.mjs run --mode showcase",
118
+ "example:run": "bun examples/todo/run-all.ts",
119
119
  "example:clients": "bun --cwd examples/todo ../../packages/core/bin/atlas.mjs run --config clients/atlas.config.ts --mode showcase",
120
120
  "example:clients:bun": "ATLAS_DESKTOP=bun bun --cwd examples/todo ../../packages/core/bin/atlas.mjs run --config clients/atlas.config.ts --mode showcase",
121
121
  "atlas:run": "status=0; bun run example:clients || status=$?; bun run example:run || status=$?; exit $status",
122
- "atlas:viz": "bun packages/visualizer/bin/atlas-visualizer.mjs dev --specs examples/todo/specs --runs examples/todo/atlas-runs",
122
+ "atlas:viz": "ATLAS_REPORTS_MODULE=\"$(pwd)/examples/todo/reports-dev.ts\" bun packages/visualizer/bin/atlas-visualizer.mjs dev --specs examples/todo/specs --runs examples/todo/atlas-runs",
123
123
  "release:plan": "bun scripts/release/plan.ts",
124
124
  "release:publish": "bun scripts/release/publish.ts",
125
125
  "release:smoke": "bun scripts/release/consumer-smoke.ts",
@@ -171,6 +171,7 @@
171
171
  "devDependencies": {
172
172
  "@commitlint/cli": "19.8.1",
173
173
  "@commitlint/config-conventional": "19.8.1",
174
+ "@informalsystems/quint": "^0.33.0",
174
175
  "@types/bun": "1.4.3",
175
176
  "@types/node": "^22.20.3",
176
177
  "@types/react": "19.2.7",
@@ -11,7 +11,9 @@ reads two independent sources and works with whatever exists:
11
11
 
12
12
  The explorer is one header, an open index and one plate. The index lists every place and journey of
13
13
  the product; each kind of spec (Gherkin, Quint, DMN, Alloy, OpenSLO) has its own tab, with the
14
- recorded result of every spec in its index. Local directories, published HTTP files and HTTP APIs
14
+ recorded result of every spec in its index. The header finds a flow in ordinary language across
15
+ charts, specs and recorded runs. ⌘K (Ctrl+K on other keyboards) opens it, and `/` does the same
16
+ when you are not already typing. Local directories, published HTTP files and HTTP APIs
15
17
  can supply reports simultaneously. Reports update through file watching, polling or server events.
16
18
  Latest includes running reports; one unavailable backend leaves the others usable.
17
19
 
@@ -6,9 +6,9 @@ import { SourcesPanel } from '../evidence/sources-panel';
6
6
  import { SpecIndex } from '../evidence/spec-index';
7
7
  import { kindOfAddress } from '../spec-kind';
8
8
  import styles from '../views/views.module.css';
9
+ import { Finder } from '../find/Finder';
9
10
  import { AtlasProvider, useAtlas } from './atlas-context';
10
11
  import { Gazetteer } from './gazetteer';
11
- import { SearchQueryProvider } from './search-query';
12
12
 
13
13
  function Index() {
14
14
  const { pathname } = useLocation();
@@ -38,18 +38,17 @@ export function AppShell() {
38
38
  return (
39
39
  <AtlasProvider run={run}>
40
40
  <EvidenceProvider>
41
- <SearchQueryProvider>
42
- <div className={styles.app}>
43
- <Header />
44
- <div className={styles.frame}>
45
- <Index />
46
- <main className={styles.main}>
47
- <Body />
48
- </main>
49
- </div>
50
- <SourcesPanel />
41
+ <div className={styles.app}>
42
+ <Header />
43
+ <div className={styles.frame}>
44
+ <Index />
45
+ <main className={styles.main}>
46
+ <Body />
47
+ </main>
51
48
  </div>
52
- </SearchQueryProvider>
49
+ <SourcesPanel />
50
+ <Finder />
51
+ </div>
53
52
  </EvidenceProvider>
54
53
  </AtlasProvider>
55
54
  );
@@ -29,8 +29,8 @@
29
29
  .row {
30
30
  display: flex;
31
31
  justify-content: space-between;
32
- align-items: baseline;
33
- gap: 12px;
32
+ align-items: center;
33
+ gap: 10px;
34
34
  padding: 6px 10px;
35
35
  border-radius: 8px;
36
36
  color: inherit;
@@ -38,6 +38,16 @@
38
38
  text-decoration: none;
39
39
  }
40
40
 
41
+ .thumb {
42
+ flex: none;
43
+ width: 28px;
44
+ }
45
+
46
+ .name {
47
+ flex: 1;
48
+ min-width: 0;
49
+ }
50
+
41
51
  .chart {
42
52
  color: var(--ink-2);
43
53
  font-weight: 600;
@@ -1,13 +1,17 @@
1
1
  import { Link, useLocation, useParams, useSearch } from '@tanstack/react-router';
2
2
 
3
+ import { ScreenImage } from '../components/Screen';
3
4
  import { STATUS_LABEL, type AtlasView, type StateView } from '../data/model';
4
5
  import { useAtlas } from './atlas-context';
5
- import { useSearchQuery } from './search-query';
6
6
  import styles from './gazetteer.module.css';
7
7
 
8
- function hit(query: string, text: string) {
9
- const needle = query.trim().toLowerCase();
10
- return needle.length === 0 || text.toLowerCase().includes(needle);
8
+ function openingScreen(view: AtlasView, steps: readonly { transitionIds: readonly string[] }[]) {
9
+ for (const step of steps) {
10
+ const transition = view.transitions.get(step.transitionIds[0] ?? '');
11
+ const landing = transition?.target ? view.states.get(transition.target) : undefined;
12
+ if (landing && (landing.kind === 'screen' || landing.kind === 'final')) return landing;
13
+ }
14
+ return undefined;
11
15
  }
12
16
 
13
17
  function screensOf(view: AtlasView, chartId: string) {
@@ -19,7 +23,6 @@ function screensOf(view: AtlasView, chartId: string) {
19
23
  }
20
24
 
21
25
  export function Gazetteer() {
22
- const { query } = useSearchQuery();
23
26
  const { view } = useAtlas();
24
27
  const params = useParams({ strict: false });
25
28
  const search = useSearch({ strict: false });
@@ -27,7 +30,7 @@ export function Gazetteer() {
27
30
  const screen = typeof search.screen === 'string' ? search.screen : undefined;
28
31
  const journeyId = typeof search.journey === 'string' ? search.journey : undefined;
29
32
  const group = typeof search.group === 'string' ? search.group : undefined;
30
- const journeys = (view?.journeys ?? []).filter((journey) => journey.chartIds.length > 0 && hit(query, journey.name));
33
+ const journeys = (view?.journeys ?? []).filter((journey) => journey.chartIds.length > 0);
31
34
  return (
32
35
  <nav className={styles.index} aria-label="Places">
33
36
  <h2>Places</h2>
@@ -38,9 +41,7 @@ export function Gazetteer() {
38
41
  {context.chartIds.map((chartId) => {
39
42
  const chart = view.charts.get(chartId);
40
43
  if (!chart) return null;
41
- const screens = screensOf(view, chartId).filter((state) => hit(query, `${state.name} ${chart.name} ${context.name}`));
42
- const chartHit = hit(query, `${chart.name} ${context.name}`);
43
- if (query.trim() && screens.length === 0 && !chartHit) return null;
44
+ const screens = screensOf(view, chartId);
44
45
  return (
45
46
  <div key={chartId}>
46
47
  <Link className={styles.chart} to="/chart/$chartId" params={{ chartId }} search={(prev) => ({ run: prev.run, screen: undefined, journey: undefined, group: undefined })} activeOptions={{ includeSearch: true, explicitUndefined: true }} aria-current={params.chartId === chartId && !screen && !journeyId && !group ? 'page' : undefined}>
@@ -57,7 +58,10 @@ export function Gazetteer() {
57
58
  search={(prev) => ({ run: prev.run, screen: state.name, journey: prev.journey })}
58
59
  aria-current={params.chartId === chartId && screen === state.name ? 'page' : undefined}
59
60
  >
60
- <span>{state.name}</span>
61
+ <span className={styles.thumb} aria-hidden="true">
62
+ <ScreenImage state={state} size="thumb" description={false} lines={false} caption={false} />
63
+ </span>
64
+ <span className={styles.name}>{state.name}</span>
61
65
  <span className={styles.status}>{STATUS_LABEL[state.status]}</span>
62
66
  </Link>
63
67
  </li>
@@ -72,21 +76,29 @@ export function Gazetteer() {
72
76
  <section>
73
77
  <h3>Journeys</h3>
74
78
  <ul>
75
- {journeys.map((journey) => (
76
- <li key={journey.id}>
77
- <Link
78
- className={styles.row}
79
- data-status={journey.status}
80
- to="/chart/$chartId"
81
- params={{ chartId: journey.chartIds[0] ?? '' }}
82
- search={(prev) => ({ run: prev.run, journey: journey.id })}
83
- aria-current={journeyId === journey.id && pathname.startsWith('/chart') ? 'page' : undefined}
84
- >
85
- <span>{journey.name}</span>
86
- <span className={styles.status}>{STATUS_LABEL[journey.status]}</span>
87
- </Link>
88
- </li>
89
- ))}
79
+ {journeys.map((journey) => {
80
+ const opening = view ? openingScreen(view, journey.steps) : undefined;
81
+ return (
82
+ <li key={journey.id}>
83
+ <Link
84
+ className={styles.row}
85
+ data-status={journey.status}
86
+ to="/chart/$chartId"
87
+ params={{ chartId: journey.chartIds[0] ?? '' }}
88
+ search={(prev) => ({ run: prev.run, journey: journey.id })}
89
+ aria-current={journeyId === journey.id && pathname.startsWith('/chart') ? 'page' : undefined}
90
+ >
91
+ {opening && (
92
+ <span className={styles.thumb} aria-hidden="true">
93
+ <ScreenImage state={opening} size="thumb" description={false} lines={false} caption={false} />
94
+ </span>
95
+ )}
96
+ <span className={styles.name}>{journey.name}</span>
97
+ <span className={styles.status}>{STATUS_LABEL[journey.status]}</span>
98
+ </Link>
99
+ </li>
100
+ );
101
+ })}
90
102
  </ul>
91
103
  </section>
92
104
  )}
@@ -94,7 +106,7 @@ export function Gazetteer() {
94
106
  <section>
95
107
  <h2>Not in the spec yet</h2>
96
108
  <ul>
97
- {view.excluded.filter((item) => hit(query, item)).map((item) => (
109
+ {view.excluded.map((item) => (
98
110
  <li key={item} className={styles.excluded}>{item}</li>
99
111
  ))}
100
112
  </ul>
@@ -137,23 +137,39 @@
137
137
  }
138
138
 
139
139
  .search {
140
+ display: inline-flex;
141
+ align-items: center;
142
+ gap: 10px;
140
143
  width: min(15rem, 28vw);
141
144
  height: 2.125rem;
142
- padding: 0 14px;
145
+ padding: 0 8px 0 14px;
143
146
  border: 1px solid transparent;
144
147
  border-radius: var(--pill);
145
148
  background: var(--bg-2);
149
+ color: var(--ink-3);
146
150
  font-size: 0.875rem;
151
+ font-weight: 500;
152
+ text-align: left;
153
+ cursor: pointer;
147
154
  }
148
155
 
149
- .search::placeholder {
156
+ .search kbd {
157
+ margin-left: auto;
158
+ padding: 0 0.35rem;
159
+ border: 1px solid var(--line-2);
160
+ border-radius: 0.35rem;
150
161
  color: var(--ink-3);
162
+ font-family: var(--font);
163
+ font-size: 0.75rem;
164
+ font-weight: 600;
165
+ line-height: 1.35rem;
151
166
  }
152
167
 
153
- .search:focus {
168
+ .search:hover,
169
+ .search:focus-visible {
154
170
  border-color: var(--place);
155
171
  background: var(--bg);
156
- outline: none;
172
+ color: var(--ink);
157
173
  }
158
174
 
159
175
  @media (max-width: 760px) {
@@ -2,7 +2,8 @@ import { Link, useLocation, useNavigate, useSearch } from '@tanstack/react-route
2
2
  import { useEffect, useMemo } from 'react';
3
3
 
4
4
  import { useAtlas } from '../app/atlas-context';
5
- import { useSearchQuery } from '../app/search-query';
5
+ import { commandKey } from '../find/Finder';
6
+ import { setFinderOpen } from '../find/open';
6
7
  import type { AtlasView } from '../data/model';
7
8
  import { LATEST } from '../data/queries';
8
9
  import { useEvidence } from '../evidence/evidence-context';
@@ -85,7 +86,6 @@ export function Header() {
85
86
  const { view } = useAtlas();
86
87
  const evidence = useEvidence();
87
88
  const { pathname } = useLocation();
88
- const { query, setQuery } = useSearchQuery();
89
89
  const kind = kindOfAddress(pathname);
90
90
  const product = productName(view?.title);
91
91
  useEffect(() => {
@@ -120,7 +120,10 @@ export function Header() {
120
120
  {!kind && view && <RunPicker view={view} />}
121
121
  {!kind && view && <DataIssues issues={view.issues} />}
122
122
  <SourcesButton />
123
- <input className={styles.search} type="search" value={query} aria-label="Find" placeholder={kind ? `Find a ${kind.one}` : 'Find a place'} onChange={(event) => setQuery(event.target.value)} />
123
+ <button type="button" className={styles.search} aria-keyshortcuts="Meta+K Control+K" onClick={() => setFinderOpen(true)}>
124
+ Find a flow
125
+ <kbd>{commandKey()}</kbd>
126
+ </button>
124
127
  </div>
125
128
  </header>
126
129
  );
@@ -108,12 +108,24 @@ export function buildAtlasView(input: BuildInput): AtlasView {
108
108
 
109
109
  const processing = input.runProgress === 'running' || run?.info.finishedAt === null;
110
110
  const itemStatus = (status: ItemStatus | undefined) => processing && status === 'not-reached' ? 'not-yet-run' : status ?? unrun;
111
+ const shotOf = (img: { png: string; webp?: string | undefined; client?: string | undefined }) => {
112
+ const full = input.resolveMedia(run!.id, img.png) || null;
113
+ const thumb = img.webp ? input.resolveMedia(run!.id, img.webp) || full : full;
114
+ if (!full && !thumb) return null;
115
+ return { client: img.client ?? null, thumb, full };
116
+ };
111
117
  const image = (img: Image | null | undefined, marked: string | undefined): ImageMedia => {
112
- if (!run || !img) return { state: marked === 'processing' || processing ? 'processing' : 'missing', thumb: null, full: null };
113
- if (marked === 'processing') return { state: 'processing', thumb: null, full: null };
114
- const full = input.resolveMedia(run.id, img.png) || null;
115
- const thumb = img.webp ? input.resolveMedia(run.id, img.webp) || full : full;
116
- return { state: full || thumb ? 'available' : 'missing', thumb, full };
118
+ const empty = (state: ImageMedia['state']): ImageMedia => ({ state, thumb: null, full: null, client: img?.client ?? null, also: [] });
119
+ if (!run || !img) return empty(marked === 'processing' || processing ? 'processing' : 'missing');
120
+ if (marked === 'processing') return empty('processing');
121
+ const main = shotOf(img);
122
+ const also = (img.also ?? []).flatMap((extra) => {
123
+ if (!extra.png) return [];
124
+ const shot = shotOf({ png: extra.png, webp: extra.webp, client: extra.client });
125
+ return shot ? [shot] : [];
126
+ });
127
+ if (!main) return { state: 'missing', thumb: null, full: null, client: img.client ?? null, also };
128
+ return { state: 'available', thumb: main.thumb, full: main.full, client: main.client, also };
117
129
  };
118
130
  const pathRef = (id: string): PathRef => ({ id, name: run?.paths.find((p) => p.id === id)?.name ?? id });
119
131
 
@@ -25,7 +25,16 @@ export type SpecChange = 'same' | 'added' | 'removed' | 'changed';
25
25
 
26
26
  export type MediaState = 'available' | 'missing' | 'processing';
27
27
 
28
- export type ImageMedia = { state: MediaState; thumb: string | null; full: string | null };
28
+ /** One client's still of a screen. `also` on ImageMedia holds the other clients at the same moment. */
29
+ export type ImageShot = { client: string | null; thumb: string | null; full: string | null };
30
+
31
+ export type ImageMedia = {
32
+ state: MediaState;
33
+ thumb: string | null;
34
+ full: string | null;
35
+ client: string | null;
36
+ also: ImageShot[];
37
+ };
29
38
 
30
39
  export type VideoMedia = {
31
40
  state: MediaState;
@@ -19,8 +19,13 @@ function explain(status: number) {
19
19
  return `Evidence could not be read (${status}).`;
20
20
  }
21
21
 
22
+ /** A captured file's address in the browser. Pictures and video use it directly; fetches use the same address. */
23
+ export function reportAssetUrl(path: string): string {
24
+ return staticEvidence ? `${reportsBase}/${evidenceFile(path).split('/').map(encodeURIComponent).join('/')}` : `${reportsBase}${path}`;
25
+ }
26
+
22
27
  export async function reportResponse(path: string, signal?: AbortSignal) {
23
- const url = staticEvidence ? `${reportsBase}/${evidenceFile(path).split('/').map(encodeURIComponent).join('/')}` : `${reportsBase}${path}`;
28
+ const url = reportAssetUrl(path);
24
29
  let response: Response;
25
30
  try {
26
31
  response = await fetch(url, { signal });