@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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { checkHistory, type CheckSlot, type HistoryEntry } from '@crvouga/atlas/reports/evidence-view';
|
|
2
|
+
import type { RunReport } from '@crvouga/atlas/reports/report-types';
|
|
2
3
|
import { useQuery } from '@tanstack/react-query';
|
|
3
4
|
import { useMemo } from 'react';
|
|
4
5
|
|
|
@@ -21,6 +22,21 @@ type Props = {
|
|
|
21
22
|
onClose: () => void;
|
|
22
23
|
};
|
|
23
24
|
|
|
25
|
+
/** HTML snapshots are the only pictures that paint with the run's stylesheet. */
|
|
26
|
+
function needsStylesheet(report: RunReport | undefined): boolean {
|
|
27
|
+
if (!report) return false;
|
|
28
|
+
const visit = (steps: RunReport['steps']): boolean => {
|
|
29
|
+
for (const step of steps) {
|
|
30
|
+
for (const frame of step.frames) {
|
|
31
|
+
if (frame.snapshots.some((blob) => blob.media === 'html')) return true;
|
|
32
|
+
}
|
|
33
|
+
if (visit(step.steps)) return true;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
};
|
|
37
|
+
return visit(report.steps) || (report.artifacts ?? []).some((artifact) => artifact.media === 'html');
|
|
38
|
+
}
|
|
39
|
+
|
|
24
40
|
/** One recorded test, opened in place of its spec. */
|
|
25
41
|
export function RecordingPage({ slot, layerId, label, back, moment, onMoment, onClose }: Props) {
|
|
26
42
|
const { evidence, backends, pending, ready, unavailable } = useEvidence();
|
|
@@ -29,7 +45,8 @@ export function RecordingPage({ slot, layerId, label, back, moment, onMoment, on
|
|
|
29
45
|
const winner = slot?.outcomes[0];
|
|
30
46
|
const chosen: HistoryEntry | null = (layerId ? history.find((entry) => entry.layer.id === layerId) : undefined) ?? (winner ? { layer: winner.layer, row: winner.row } : null);
|
|
31
47
|
const { paths, report } = useRecordedReport(chosen);
|
|
32
|
-
const
|
|
48
|
+
const wantsCss = needsStylesheet(report.data);
|
|
49
|
+
const css = useQuery({ queryKey: ['evidence-css', paths?.css ?? ''], queryFn: ({ signal }) => reportText(paths!.css, signal), enabled: paths !== null && wantsCss, staleTime: Number.POSITIVE_INFINITY, retry: false });
|
|
33
50
|
const waiting = !chosen && !unavailable && (!ready || pending > 0);
|
|
34
51
|
return (
|
|
35
52
|
<article className={styles.page} data-wide="true" aria-label="Recorded test">
|
|
@@ -57,7 +74,7 @@ export function RecordingPage({ slot, layerId, label, back, moment, onMoment, on
|
|
|
57
74
|
<button type="button" className={styles.link} onClick={() => void report.refetch()}>Try again</button>
|
|
58
75
|
</p>
|
|
59
76
|
)}
|
|
60
|
-
{css.isError && (
|
|
77
|
+
{wantsCss && css.isError && (
|
|
61
78
|
<p className={styles.notice} role="alert">
|
|
62
79
|
The recorded stylesheet could not be read, so pictures appear unstyled.{' '}
|
|
63
80
|
<button type="button" className={styles.link} onClick={() => void css.refetch()}>Try again</button>
|
|
@@ -214,10 +214,8 @@
|
|
|
214
214
|
display: flex;
|
|
215
215
|
flex-wrap: wrap;
|
|
216
216
|
align-content: flex-start;
|
|
217
|
+
justify-content: center;
|
|
217
218
|
gap: 20px;
|
|
218
|
-
height: 29rem;
|
|
219
|
-
overflow: auto;
|
|
220
|
-
scrollbar-gutter: stable;
|
|
221
219
|
}
|
|
222
220
|
|
|
223
221
|
.snapshot {
|
|
@@ -265,17 +263,15 @@
|
|
|
265
263
|
.image {
|
|
266
264
|
display: block;
|
|
267
265
|
width: 100%;
|
|
268
|
-
height:
|
|
266
|
+
height: auto;
|
|
267
|
+
max-height: min(68vh, 640px);
|
|
269
268
|
object-fit: contain;
|
|
270
|
-
|
|
271
|
-
background: #fff;
|
|
272
|
-
max-width: 100%;
|
|
269
|
+
background: var(--bg-2);
|
|
273
270
|
border: 1px solid var(--line-2);
|
|
274
271
|
border-radius: 12px;
|
|
275
272
|
}
|
|
276
273
|
|
|
277
274
|
@media (max-width: 800px) {
|
|
278
|
-
.pictures { height: 24rem; }
|
|
279
275
|
.snapshot { flex-basis: min(270px, 100%); width: min(270px, 100%); }
|
|
280
276
|
}
|
|
281
277
|
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { flattenSteps, neighborFailure, orderedUiBlobs, selectedFrame, testFrames } from '@crvouga/atlas/reports/frames';
|
|
2
2
|
import { performanceFrameIndex, performanceRows, type PerformanceRow } from '@crvouga/atlas/reports/performance';
|
|
3
3
|
import type { ReportStep, RunReport } from '@crvouga/atlas/reports/report-types';
|
|
4
|
-
import {
|
|
4
|
+
import { useMemo, useState } from 'react';
|
|
5
5
|
|
|
6
|
+
import { reportLooks } from '../looks/collect';
|
|
7
|
+
import { Stage, type LookMode } from '../looks/Stage';
|
|
8
|
+
import { reportAssetUrl } from './api';
|
|
6
9
|
import { duration } from './format';
|
|
7
10
|
import { StatusMark } from './marks';
|
|
8
11
|
import styles from './recording.module.css';
|
|
@@ -20,63 +23,35 @@ type Props = {
|
|
|
20
23
|
|
|
21
24
|
/** A recorded test, one captured moment at a time. */
|
|
22
25
|
export function Timeline({ report, fileUrl, css, moment, onMoment }: Props) {
|
|
23
|
-
const [playing, setPlaying] = useState(false);
|
|
24
26
|
const [actor, setActor] = useState('all');
|
|
25
27
|
const [tab, setTab] = useState<'moments' | 'timing'>('moments');
|
|
28
|
+
const [mode, setMode] = useState<LookMode | null>(null);
|
|
26
29
|
const test = useMemo(() => ({ report, blobs: {} }), [report]);
|
|
27
30
|
const frames = useMemo(() => testFrames(test), [test]);
|
|
31
|
+
const looks = useMemo(() => reportLooks(report, (file) => reportAssetUrl(fileUrl(file))), [report, fileUrl]);
|
|
32
|
+
const extras = (report.artifacts ?? []).filter((artifact) => artifact.media !== 'png' && artifact.media !== 'video');
|
|
28
33
|
const frame = selectedFrame(frames, { view: 'test', testId: report.meta.testId, params: { frame: moment === null ? '' : String(moment) } });
|
|
29
34
|
const index = frame?.index ?? 0;
|
|
30
35
|
const last = frames.length - 1;
|
|
31
36
|
const go = (target: number) => {
|
|
32
37
|
if (frames.length) onMoment(Math.max(0, Math.min(last, target)) + 1, true);
|
|
33
38
|
};
|
|
34
|
-
// Timers and key handlers outlive a render; they always move from the moment on screen.
|
|
35
|
-
const goNow = useRef(go);
|
|
36
|
-
goNow.current = go;
|
|
37
39
|
const steps = useMemo(() => flattenSteps(report.steps).filter((entry) => entry.step.kind !== 'quint'), [report.steps]);
|
|
38
40
|
const rows = useMemo(() => performanceRows(report.steps, report.meta.performance?.slowStepMs ?? 50), [report]);
|
|
39
41
|
const byStep = useMemo(() => performanceFrameIndex(rows, frames), [rows, frames]);
|
|
40
42
|
const jump = (step: ReportStep) => {
|
|
41
43
|
const target = byStep.get(step);
|
|
42
44
|
if (!target) return;
|
|
43
|
-
|
|
45
|
+
setMode('timeline');
|
|
44
46
|
setTab('moments');
|
|
45
47
|
go(target.index);
|
|
46
48
|
};
|
|
47
|
-
|
|
48
|
-
const pause = frame?.frame.durationMs;
|
|
49
|
-
useEffect(() => {
|
|
50
|
-
if (!playing) return;
|
|
51
|
-
if (index >= last) {
|
|
52
|
-
setPlaying(false);
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
const timer = setTimeout(() => goNow.current(index + 1), Math.max(100, Math.min(pause ?? 500, 2000)));
|
|
56
|
-
return () => clearTimeout(timer);
|
|
57
|
-
}, [playing, index, last, pause]);
|
|
58
|
-
|
|
59
|
-
useEffect(() => {
|
|
60
|
-
const onKey = (event: KeyboardEvent) => {
|
|
61
|
-
if (event.altKey || event.ctrlKey || event.metaKey || !frames.length) return;
|
|
62
|
-
if (event.target instanceof HTMLElement && (event.target.isContentEditable || event.target.closest('input, textarea, select, button'))) return;
|
|
63
|
-
const target = event.key === 'ArrowRight' ? index + 1 : event.key === 'ArrowLeft' ? index - 1 : event.key === 'Home' ? 0 : event.key === 'End' ? last : null;
|
|
64
|
-
if (target === null) return;
|
|
65
|
-
event.preventDefault();
|
|
66
|
-
setPlaying(false);
|
|
67
|
-
goNow.current(target);
|
|
68
|
-
};
|
|
69
|
-
window.addEventListener('keydown', onKey);
|
|
70
|
-
return () => window.removeEventListener('keydown', onKey);
|
|
71
|
-
}, [index, last, frames.length]);
|
|
72
|
-
|
|
73
|
-
const ui = frame ? orderedUiBlobs(frame, test) : [];
|
|
74
|
-
const pictures = ui.filter((entry) => entry.blob && (actor === 'all' || entry.nodeId === actor));
|
|
75
|
-
const absent = ui.filter((entry) => !entry.blob && (actor === 'all' || entry.nodeId === actor));
|
|
76
|
-
const observations = frame?.frame.snapshots.filter((blob) => blob.media !== 'html' && blob.media !== 'png' && (actor === 'all' || !blob.nodeId || blob.nodeId === actor)) ?? [];
|
|
77
49
|
const nextFailure = () => {
|
|
78
50
|
const failure = neighborFailure(frames, index, 1);
|
|
79
|
-
if (failure)
|
|
51
|
+
if (failure) {
|
|
52
|
+
setMode('timeline');
|
|
53
|
+
go(failure.index);
|
|
54
|
+
}
|
|
80
55
|
};
|
|
81
56
|
|
|
82
57
|
return (
|
|
@@ -115,53 +90,69 @@ export function Timeline({ report, fileUrl, css, moment, onMoment }: Props) {
|
|
|
115
90
|
})}
|
|
116
91
|
</ol>
|
|
117
92
|
<section className={styles.stage} aria-label="Recorded moment">
|
|
118
|
-
|
|
119
|
-
<
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
<span className={styles.position}>{frames.length ? `Moment ${index + 1} of ${frames.length}` : 'No recorded moments'}</span>
|
|
126
|
-
<button type="button" className={styles.control} disabled={!frames.some((entry) => entry.step.status === 'failed')} onClick={nextFailure}>Next failure</button>
|
|
127
|
-
{report.meta.actorIds.length > 1 && (
|
|
128
|
-
<label className={styles.actor}>
|
|
129
|
-
<span className="sr-only">Person</span>
|
|
130
|
-
<select value={actor} onChange={(event) => setActor(event.target.value)}>
|
|
131
|
-
<option value="all">Everyone</option>
|
|
132
|
-
{report.meta.actorIds.map((id) => (
|
|
133
|
-
<option key={id}>{id}</option>
|
|
134
|
-
))}
|
|
135
|
-
</select>
|
|
136
|
-
</label>
|
|
137
|
-
)}
|
|
138
|
-
</div>
|
|
139
|
-
{!frame && <p className={styles.quietLine}>This test recorded no moments. Its steps and outcome are on the left.</p>}
|
|
140
|
-
{frame && (
|
|
141
|
-
<>
|
|
142
|
-
<h3 className={styles.momentTitle} data-status={frame.step.status}>{frame.step.title}</h3>
|
|
143
|
-
<p className={styles.quietLine}>
|
|
144
|
-
{frame.step.status === 'failed' ? 'Failed' : 'Passed'} · at {duration(Math.max(0, frame.frame.capturedAtMs - report.meta.startedAtMs)) || '0 ms'}
|
|
145
|
-
{frame.frame.hit && <> · acted on {frame.frame.hit.path.join(' › ')}</>}
|
|
146
|
-
</p>
|
|
147
|
-
{frame.step.error && <pre className={styles.error} role="alert">{frame.step.error}</pre>}
|
|
148
|
-
{frame.step.detail && (
|
|
149
|
-
<div className={styles.observed}>
|
|
150
|
-
<SnapshotData text={frame.step.detail} />
|
|
151
|
-
</div>
|
|
152
|
-
)}
|
|
153
|
-
<div className={styles.pictures} aria-label="Recorded snapshots">
|
|
154
|
-
{pictures.map((entry) => (
|
|
155
|
-
<Snapshot key={entry.blob!.file} url={fileUrl(entry.blob!.file)} blob={entry.blob!} css={css} hit={entry.stale ? null : frame.frame.hit ?? null} earlier={entry.stale} />
|
|
156
|
-
))}
|
|
157
|
-
{absent.map((entry) => (
|
|
158
|
-
<p key={entry.nodeId} className={styles.quietLine}>No picture of {entry.nodeId} had been recorded by this moment.</p>
|
|
93
|
+
{report.meta.actorIds.length > 1 && (
|
|
94
|
+
<label className={styles.actor}>
|
|
95
|
+
<span className="sr-only">Person</span>
|
|
96
|
+
<select value={actor} onChange={(event) => setActor(event.target.value)}>
|
|
97
|
+
<option value="all">Everyone</option>
|
|
98
|
+
{report.meta.actorIds.map((id) => (
|
|
99
|
+
<option key={id}>{id}</option>
|
|
159
100
|
))}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
101
|
+
</select>
|
|
102
|
+
</label>
|
|
103
|
+
)}
|
|
104
|
+
<Stage
|
|
105
|
+
looks={looks}
|
|
106
|
+
prefer="timeline"
|
|
107
|
+
mode={mode}
|
|
108
|
+
onMode={setMode}
|
|
109
|
+
moment={frames.length ? index : undefined}
|
|
110
|
+
onMoment={go}
|
|
111
|
+
failure={frames.some((entry) => entry.step.status === 'failed')}
|
|
112
|
+
onFailure={nextFailure}
|
|
113
|
+
empty={extras.length > 0
|
|
114
|
+
? <p className={styles.quietLine}>This runner attached files instead of a step-by-step timeline. They are below.</p>
|
|
115
|
+
: <p className={styles.quietLine}>This test recorded no picture, video, or timeline. Its steps and outcome are on the left.</p>}
|
|
116
|
+
renderMoment={(at) => {
|
|
117
|
+
const current = frames[at];
|
|
118
|
+
if (!current) return null;
|
|
119
|
+
const ui = orderedUiBlobs(current, test);
|
|
120
|
+
const pictures = ui.filter((entry) => entry.blob && entry.blob.media !== 'video' && (actor === 'all' || entry.nodeId === actor));
|
|
121
|
+
const absent = ui.filter((entry) => !entry.blob && (actor === 'all' || !entry.nodeId || entry.nodeId === actor));
|
|
122
|
+
const observations = current.frame.snapshots.filter((blob) => blob.media !== 'html' && blob.media !== 'png' && blob.media !== 'video' && (actor === 'all' || !blob.nodeId || blob.nodeId === actor));
|
|
123
|
+
return (
|
|
124
|
+
<>
|
|
125
|
+
<p className={styles.quietLine}>
|
|
126
|
+
{current.step.status === 'failed' ? 'Failed' : 'Passed'} · at {duration(Math.max(0, current.frame.capturedAtMs - report.meta.startedAtMs)) || '0 ms'}
|
|
127
|
+
{current.frame.hit && <> · acted on {current.frame.hit.path.join(' › ')}</>}
|
|
128
|
+
</p>
|
|
129
|
+
{current.step.error && <pre className={styles.error} role="alert">{current.step.error}</pre>}
|
|
130
|
+
{current.step.detail && (
|
|
131
|
+
<div className={styles.observed}>
|
|
132
|
+
<SnapshotData text={current.step.detail} />
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
<div className={styles.pictures} aria-label="Recorded snapshots">
|
|
136
|
+
{pictures.map((entry) => (
|
|
137
|
+
<Snapshot key={entry.blob!.file} url={fileUrl(entry.blob!.file)} blob={entry.blob!} css={css} hit={entry.stale ? null : current.frame.hit ?? null} earlier={entry.stale} />
|
|
138
|
+
))}
|
|
139
|
+
{absent.map((entry) => (
|
|
140
|
+
<p key={entry.nodeId} className={styles.quietLine}>No picture of {entry.nodeId} had been recorded by this moment.</p>
|
|
141
|
+
))}
|
|
142
|
+
{observations.map((blob) => (
|
|
143
|
+
<Snapshot key={blob.file} url={fileUrl(blob.file)} blob={blob} css={css} hit={null} />
|
|
144
|
+
))}
|
|
145
|
+
</div>
|
|
146
|
+
</>
|
|
147
|
+
);
|
|
148
|
+
}}
|
|
149
|
+
/>
|
|
150
|
+
{extras.length > 0 && (
|
|
151
|
+
<div className={styles.pictures} aria-label="Other attachments">
|
|
152
|
+
{extras.map((artifact) => (
|
|
153
|
+
<Snapshot key={artifact.file} url={fileUrl(artifact.file)} blob={{ name: artifact.name, media: artifact.media, file: artifact.file }} css={css} hit={null} />
|
|
154
|
+
))}
|
|
155
|
+
</div>
|
|
165
156
|
)}
|
|
166
157
|
</section>
|
|
167
158
|
</div>
|
|
@@ -4,7 +4,7 @@ import { createSnapshotHost, paintSnapshotHost, type HitResult } from '@crvouga/
|
|
|
4
4
|
import { useQuery } from '@tanstack/react-query';
|
|
5
5
|
import { useEffect, useRef, useState } from 'react';
|
|
6
6
|
|
|
7
|
-
import { reportResponse } from './api';
|
|
7
|
+
import { reportAssetUrl, reportResponse } from './api';
|
|
8
8
|
import styles from './recording.module.css';
|
|
9
9
|
import { traceStates } from './quint';
|
|
10
10
|
import { ModelTrace } from './model-trace';
|
|
@@ -45,9 +45,9 @@ export function SnapshotPicture({ file, html, css, hit, who }: { file: string; h
|
|
|
45
45
|
|
|
46
46
|
/** One captured picture or value, exactly as recorded. */
|
|
47
47
|
export function Snapshot({ url, blob, css, hit, earlier = false }: { url: string; blob: ReportSnapshotBlob; css: string; hit: ReportStepHit | null; earlier?: boolean }) {
|
|
48
|
-
const content = useQuery({ queryKey: ['evidence-blob', url], queryFn: ({ signal }) => readBlob(url, blob, signal), retry: false });
|
|
48
|
+
const content = useQuery({ queryKey: ['evidence-blob', url], queryFn: ({ signal }) => readBlob(url, blob, signal), enabled: blob.media !== 'video', retry: false });
|
|
49
49
|
const who = blob.nodeId ?? blob.name;
|
|
50
|
-
const picture = blob.media === 'html' || blob.media === 'png';
|
|
50
|
+
const picture = blob.media === 'html' || blob.media === 'png' || blob.media === 'video';
|
|
51
51
|
return (
|
|
52
52
|
<figure className={styles.snapshot} data-picture={picture || undefined}>
|
|
53
53
|
<figcaption>
|
|
@@ -55,7 +55,8 @@ export function Snapshot({ url, blob, css, hit, earlier = false }: { url: string
|
|
|
55
55
|
{blob.nodeId && blob.name !== blob.nodeId && <span>{blob.name}</span>}
|
|
56
56
|
{earlier && <span className={styles.earlier}>Earlier picture</span>}
|
|
57
57
|
</figcaption>
|
|
58
|
-
{
|
|
58
|
+
{blob.media === 'video' && <video className={styles.image} src={reportAssetUrl(url)} controls playsInline aria-label={`Recorded ${who}`} />}
|
|
59
|
+
{blob.media !== 'video' && content.isPending && <p className={styles.quietLine} role="status">Loading…</p>}
|
|
59
60
|
{content.isError && (
|
|
60
61
|
<p role="alert" className={styles.quietLine}>
|
|
61
62
|
{content.error.message}{' '}
|
|
@@ -2,7 +2,6 @@ import { addSummaries, NO_EVIDENCE, type SpecSummary } from '@crvouga/atlas/repo
|
|
|
2
2
|
import { Link, useSearch } from '@tanstack/react-router';
|
|
3
3
|
import { useMemo } from 'react';
|
|
4
4
|
|
|
5
|
-
import { useSearchQuery } from '../app/search-query';
|
|
6
5
|
import { folderOf, outlinePath, specFile, type SpecKind } from '../spec-kind';
|
|
7
6
|
import { useUiStore, type ResultFilter } from '../state/ui-store';
|
|
8
7
|
import { useEvidence } from './evidence-context';
|
|
@@ -16,15 +15,9 @@ const FILTERS: readonly { readonly id: ResultFilter; readonly label: string; rea
|
|
|
16
15
|
{ id: 'none', label: 'None', keeps: (summary) => summary.status === 'none' }
|
|
17
16
|
];
|
|
18
17
|
|
|
19
|
-
function hit(query: string, text: string) {
|
|
20
|
-
const needle = query.trim().toLowerCase();
|
|
21
|
-
return needle.length === 0 || text.toLowerCase().includes(needle);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
18
|
/** Every spec of one kind, grouped by folder, each with what the combined report holds for it. */
|
|
25
19
|
export function SpecIndex({ kind }: { kind: SpecKind }) {
|
|
26
20
|
const evidence = useEvidence();
|
|
27
|
-
const { query } = useSearchQuery();
|
|
28
21
|
const search = useSearch({ strict: false });
|
|
29
22
|
const filter = useUiStore((s) => s.resultFilter);
|
|
30
23
|
const setFilter = useUiStore((s) => s.setResultFilter);
|
|
@@ -34,9 +27,8 @@ export function SpecIndex({ kind }: { kind: SpecKind }) {
|
|
|
34
27
|
// `titleOf` reads the outline the view was built from.
|
|
35
28
|
[evidence.files, evidence.view, kind]
|
|
36
29
|
);
|
|
37
|
-
const searched = rows.filter((row) => hit(query, `${row.title} ${row.file}`));
|
|
38
30
|
const keeps = FILTERS.find((entry) => entry.id === filter)!.keeps;
|
|
39
|
-
const visible =
|
|
31
|
+
const visible = rows.filter((row) => keeps(row.summary));
|
|
40
32
|
const groups = useMemo(() => {
|
|
41
33
|
const byFolder = new Map<string, typeof visible>();
|
|
42
34
|
for (const row of visible) byFolder.set(folderOf(row.file), [...(byFolder.get(folderOf(row.file)) ?? []), row]);
|
|
@@ -47,7 +39,7 @@ export function SpecIndex({ kind }: { kind: SpecKind }) {
|
|
|
47
39
|
<div className={styles.filters} role="group" aria-label="Show">
|
|
48
40
|
{FILTERS.map((entry) => (
|
|
49
41
|
<button key={entry.id} type="button" aria-pressed={filter === entry.id} onClick={() => setFilter(entry.id)}>
|
|
50
|
-
{entry.label} <span>{
|
|
42
|
+
{entry.label} <span>{rows.filter((row) => entry.keeps(row.summary)).length}</span>
|
|
51
43
|
</button>
|
|
52
44
|
))}
|
|
53
45
|
</div>
|
|
@@ -55,7 +47,7 @@ export function SpecIndex({ kind }: { kind: SpecKind }) {
|
|
|
55
47
|
{evidence.filesError && <p className={styles.indexNote}>The file list could not be loaded.</p>}
|
|
56
48
|
{!evidence.filesPending && !evidence.filesError && visible.length === 0 && (
|
|
57
49
|
<p className={styles.indexNote}>
|
|
58
|
-
{rows.length === 0 ? `No ${kind.many} in this spec set.` :
|
|
50
|
+
{rows.length === 0 ? `No ${kind.many} in this spec set.` : filter === 'failed' ? `No failing ${kind.many}.` : filter === 'results' ? `No ${kind.many} with results yet.` : `Every ${kind.one} has results.`}
|
|
59
51
|
</p>
|
|
60
52
|
)}
|
|
61
53
|
{groups.map(([folder, entries]) => (
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
.overlay {
|
|
2
|
+
position: fixed;
|
|
3
|
+
inset: 0;
|
|
4
|
+
z-index: 40;
|
|
5
|
+
background: rgba(15, 20, 25, 0.28);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.content {
|
|
9
|
+
position: fixed;
|
|
10
|
+
z-index: 41;
|
|
11
|
+
top: 12vh;
|
|
12
|
+
left: 50%;
|
|
13
|
+
width: min(40rem, calc(100vw - 1.5rem));
|
|
14
|
+
transform: translateX(-50%);
|
|
15
|
+
border: 1px solid var(--line);
|
|
16
|
+
border-radius: var(--radius-lg);
|
|
17
|
+
background: var(--bg);
|
|
18
|
+
box-shadow: var(--shadow-pop);
|
|
19
|
+
overflow: hidden;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.command {
|
|
23
|
+
display: flex;
|
|
24
|
+
flex-direction: column;
|
|
25
|
+
max-height: min(32rem, 70vh);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.input {
|
|
29
|
+
width: 100%;
|
|
30
|
+
height: 3.25rem;
|
|
31
|
+
padding: 0 1rem;
|
|
32
|
+
border: 0;
|
|
33
|
+
border-bottom: 1px solid var(--line);
|
|
34
|
+
background: transparent;
|
|
35
|
+
font-size: 1rem;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.input:focus {
|
|
39
|
+
outline: none;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.list {
|
|
43
|
+
min-height: 0;
|
|
44
|
+
overflow: auto;
|
|
45
|
+
padding: 0.35rem;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.empty {
|
|
49
|
+
margin: 0;
|
|
50
|
+
padding: 1.25rem 0.75rem;
|
|
51
|
+
color: var(--ink-3);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.item {
|
|
55
|
+
display: flex;
|
|
56
|
+
align-items: center;
|
|
57
|
+
gap: 0.65rem;
|
|
58
|
+
width: 100%;
|
|
59
|
+
padding: 0.45rem 0.6rem;
|
|
60
|
+
border-radius: var(--radius-sm);
|
|
61
|
+
cursor: pointer;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.item[data-selected='true'] {
|
|
65
|
+
background: var(--place-soft);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.text {
|
|
69
|
+
display: flex;
|
|
70
|
+
flex-direction: column;
|
|
71
|
+
min-width: 0;
|
|
72
|
+
flex: 1;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.title {
|
|
76
|
+
overflow: hidden;
|
|
77
|
+
color: var(--ink);
|
|
78
|
+
font-weight: 600;
|
|
79
|
+
text-overflow: ellipsis;
|
|
80
|
+
white-space: nowrap;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.snippet {
|
|
84
|
+
overflow: hidden;
|
|
85
|
+
color: var(--ink-3);
|
|
86
|
+
font-size: 0.8125rem;
|
|
87
|
+
text-overflow: ellipsis;
|
|
88
|
+
white-space: nowrap;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.meta {
|
|
92
|
+
display: flex;
|
|
93
|
+
align-items: center;
|
|
94
|
+
gap: 0.5rem;
|
|
95
|
+
flex: none;
|
|
96
|
+
max-width: 45%;
|
|
97
|
+
color: var(--ink-3);
|
|
98
|
+
font-size: 0.75rem;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.subtitle {
|
|
102
|
+
overflow: hidden;
|
|
103
|
+
text-overflow: ellipsis;
|
|
104
|
+
white-space: nowrap;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.kind {
|
|
108
|
+
flex: none;
|
|
109
|
+
font-weight: 600;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.footer {
|
|
113
|
+
display: flex;
|
|
114
|
+
gap: 1rem;
|
|
115
|
+
padding: 0.45rem 0.9rem;
|
|
116
|
+
border-top: 1px solid var(--line);
|
|
117
|
+
color: var(--ink-3);
|
|
118
|
+
font-size: 0.75rem;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.help {
|
|
122
|
+
position: fixed;
|
|
123
|
+
z-index: 42;
|
|
124
|
+
top: 18vh;
|
|
125
|
+
left: 50%;
|
|
126
|
+
width: min(26rem, calc(100vw - 1.5rem));
|
|
127
|
+
transform: translateX(-50%);
|
|
128
|
+
padding: 1.25rem 1.35rem 1rem;
|
|
129
|
+
border: 1px solid var(--line);
|
|
130
|
+
border-radius: var(--radius-lg);
|
|
131
|
+
background: var(--bg);
|
|
132
|
+
box-shadow: var(--shadow-pop);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.help h2 {
|
|
136
|
+
margin: 0 0 0.75rem;
|
|
137
|
+
font-size: 1rem;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.help ul {
|
|
141
|
+
margin: 0;
|
|
142
|
+
padding: 0;
|
|
143
|
+
list-style: none;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.help li {
|
|
147
|
+
display: flex;
|
|
148
|
+
justify-content: space-between;
|
|
149
|
+
gap: 1rem;
|
|
150
|
+
padding: 0.35rem 0;
|
|
151
|
+
border-top: 1px solid var(--line);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.help kbd {
|
|
155
|
+
color: var(--ink);
|
|
156
|
+
font-family: var(--font);
|
|
157
|
+
font-weight: 600;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.help button {
|
|
161
|
+
margin-top: 0.85rem;
|
|
162
|
+
padding: 0.35rem 0.8rem;
|
|
163
|
+
border: 1px solid var(--line-2);
|
|
164
|
+
border-radius: var(--pill);
|
|
165
|
+
background: var(--bg);
|
|
166
|
+
font-weight: 600;
|
|
167
|
+
cursor: pointer;
|
|
168
|
+
}
|