@crvouga/atlas 0.1.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.
- package/CHANGELOG.md +12 -0
- package/README.md +17 -8
- package/dist/reports/src/export-evidence.js +2 -10
- package/dist/reports/src/frames.d.ts +3 -1
- package/dist/reports/src/frames.js +21 -0
- package/dist/reports/src/publish.js +2 -11
- package/dist/reports/src/recording-keys.js +4 -0
- package/dist/reports/src/report-types.d.ts +19 -2
- package/dist/reports/src/source-entries.js +2 -12
- package/package.json +5 -3
- package/packages/visualizer/README.md +3 -1
- package/packages/visualizer/src/app/AppShell.tsx +11 -12
- package/packages/visualizer/src/app/gazetteer.module.css +12 -2
- package/packages/visualizer/src/app/gazetteer.tsx +38 -26
- package/packages/visualizer/src/components/Header.module.css +20 -4
- package/packages/visualizer/src/components/Header.tsx +6 -3
- package/packages/visualizer/src/data/model/build.ts +17 -5
- package/packages/visualizer/src/data/model/types.ts +10 -1
- package/packages/visualizer/src/evidence/api.ts +6 -1
- package/packages/visualizer/src/evidence/recording-page.tsx +19 -2
- package/packages/visualizer/src/evidence/recording.module.css +4 -8
- package/packages/visualizer/src/evidence/recording.tsx +74 -83
- package/packages/visualizer/src/evidence/snapshot.tsx +5 -4
- package/packages/visualizer/src/evidence/spec-index.tsx +3 -11
- package/packages/visualizer/src/find/Finder.module.css +168 -0
- package/packages/visualizer/src/find/Finder.tsx +303 -0
- package/packages/visualizer/src/find/corpus.test.ts +77 -0
- package/packages/visualizer/src/find/corpus.ts +368 -0
- package/packages/visualizer/src/find/open.ts +51 -0
- package/packages/visualizer/src/find/rank.test.ts +237 -0
- package/packages/visualizer/src/find/rank.ts +405 -0
- package/packages/visualizer/src/find/semantic.test.ts +62 -0
- package/packages/visualizer/src/find/semantic.ts +93 -0
- package/packages/visualizer/src/find/semantic.worker.ts +40 -0
- package/packages/visualizer/src/looks/Stage.tsx +322 -0
- package/packages/visualizer/src/looks/collect.test.ts +158 -0
- package/packages/visualizer/src/looks/collect.ts +245 -0
- package/packages/visualizer/src/looks/stage.module.css +300 -0
- package/packages/visualizer/src/reachable.test.ts +1 -1
- package/packages/visualizer/src/views/SidePanel.tsx +39 -49
- package/packages/visualizer/src/app/search-query.tsx +0 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog: @crvouga/atlas
|
|
2
2
|
|
|
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)
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
- browse every chart in the viz ([2f4f97e](https://github.com/crvouga/atlas/commit/2f4f97e132690e5bd489da8cb875fc576b10a500))
|
|
14
|
+
|
|
3
15
|
## 0.1.0 (2026-10-12)
|
|
4
16
|
|
|
5
17
|
Initial release.
|
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,21 +102,24 @@ 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
|
|
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
|
|
105
109
|
bun run example:clients:bun # the same, with the desktop in Bun's WebKit and the phone in Playwright's Chromium
|
|
106
110
|
```
|
|
107
111
|
|
|
108
|
-
To
|
|
109
|
-
|
|
112
|
+
To run every chart of the example and browse the results, run these in separate terminals; the
|
|
113
|
+
visualizer updates when a run finishes.
|
|
110
114
|
|
|
111
115
|
```sh
|
|
112
|
-
bun run example:run
|
|
113
|
-
bun run
|
|
116
|
+
bun run atlas:run # example:clients, then example:run: every chart of the example, in showcase mode
|
|
117
|
+
bun run atlas:viz # the example's specs, chart runs, Gherkin features, and Quint model
|
|
114
118
|
```
|
|
115
119
|
|
|
120
|
+
Each config writes its own run and the visualizer shows one run at a time: the latest is the
|
|
121
|
+
single-browser run, and the run across clients is the one before it in the run picker.
|
|
122
|
+
|
|
116
123
|
A showcase run ends with:
|
|
117
124
|
|
|
118
125
|
```
|
|
@@ -121,6 +128,8 @@ Transitions 14/14 passed, 0 failed, 0 not reached
|
|
|
121
128
|
Manifest: examples/todo/atlas-runs/<run-id>/manifest.json
|
|
122
129
|
```
|
|
123
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
|
+
|
|
124
133
|
`bun test examples` runs [`model.test.ts`](examples/todo/model.test.ts): the same charts and journeys
|
|
125
134
|
against `TodoModel`, a plain class, through `functionDriver` and `describeAtlas`. Only the driver
|
|
126
135
|
and the implementation differ, which is the point: the spec belongs to the product, not to one
|
|
@@ -448,9 +457,9 @@ export default defineConfig<Clients>({
|
|
|
448
457
|
- If one client cannot open (no simulator booted), the others are closed and the attempt fails
|
|
449
458
|
naming that client.
|
|
450
459
|
|
|
451
|
-
[`examples/todo/clients`](examples/todo/clients) runs a chart
|
|
452
|
-
sync server; pick each device's driver with `ATLAS_PHONE` and
|
|
453
|
-
(`playwright`, `bun` or `webdriver`).
|
|
460
|
+
[`examples/todo/clients`](examples/todo/clients) runs a chart (`specs/clients/shared-list.scxml`)
|
|
461
|
+
across a phone, a desktop and the sync server; pick each device's driver with `ATLAS_PHONE` and
|
|
462
|
+
`ATLAS_DESKTOP` (`playwright`, `bun` or `webdriver`).
|
|
454
463
|
|
|
455
464
|
## Writing a driver
|
|
456
465
|
|
|
@@ -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(
|
|
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
|
-
/**
|
|
22
|
-
|
|
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
|
|
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.
|
|
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,10 +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
|
|
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": "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",
|
|
122
123
|
"release:plan": "bun scripts/release/plan.ts",
|
|
123
124
|
"release:publish": "bun scripts/release/publish.ts",
|
|
124
125
|
"release:smoke": "bun scripts/release/consumer-smoke.ts",
|
|
@@ -170,6 +171,7 @@
|
|
|
170
171
|
"devDependencies": {
|
|
171
172
|
"@commitlint/cli": "19.8.1",
|
|
172
173
|
"@commitlint/config-conventional": "19.8.1",
|
|
174
|
+
"@informalsystems/quint": "^0.33.0",
|
|
173
175
|
"@types/bun": "1.4.3",
|
|
174
176
|
"@types/node": "^22.20.3",
|
|
175
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.
|
|
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
|
-
<
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
<
|
|
47
|
-
|
|
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
|
-
|
|
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:
|
|
33
|
-
gap:
|
|
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
|
|
9
|
-
const
|
|
10
|
-
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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.
|
|
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
|
|
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:
|
|
168
|
+
.search:hover,
|
|
169
|
+
.search:focus-visible {
|
|
154
170
|
border-color: var(--place);
|
|
155
171
|
background: var(--bg);
|
|
156
|
-
|
|
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 {
|
|
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
|
-
<
|
|
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
|
-
|
|
113
|
-
if (marked === 'processing'
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
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
|
-
|
|
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 =
|
|
28
|
+
const url = reportAssetUrl(path);
|
|
24
29
|
let response: Response;
|
|
25
30
|
try {
|
|
26
31
|
response = await fetch(url, { signal });
|