@crvouga/atlas 0.10.2 → 0.11.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 (99) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +7 -0
  3. package/dist/server/src/settings-page.d.ts +4 -4
  4. package/dist/server/src/settings-page.js +17 -78
  5. package/docs/reference/server.md +4 -4
  6. package/docs/ui.md +142 -0
  7. package/docs/workspaces.md +130 -0
  8. package/llms-full.txt +278 -0
  9. package/llms.txt +2 -0
  10. package/package.json +17 -10
  11. package/packages/visualizer/src/admin/SettingsPage.tsx +456 -0
  12. package/packages/visualizer/src/admin/main.tsx +11 -0
  13. package/packages/visualizer/src/app/AppShell.tsx +62 -38
  14. package/packages/visualizer/src/app/gazetteer.tsx +90 -83
  15. package/packages/visualizer/src/app/router.tsx +33 -15
  16. package/packages/visualizer/src/components/ActivityIndicator.tsx +4 -5
  17. package/packages/visualizer/src/components/DataIssues.tsx +36 -54
  18. package/packages/visualizer/src/components/Header.tsx +197 -125
  19. package/packages/visualizer/src/components/Legend.tsx +13 -41
  20. package/packages/visualizer/src/components/ProjectPicker.tsx +61 -26
  21. package/packages/visualizer/src/components/Screen.tsx +80 -71
  22. package/packages/visualizer/src/components/SpecLogo.tsx +15 -0
  23. package/packages/visualizer/src/components/Status.tsx +20 -34
  24. package/packages/visualizer/src/components/labels.tsx +8 -3
  25. package/packages/visualizer/src/components/spec-logos/APACHE-2.0.txt +206 -0
  26. package/packages/visualizer/src/components/spec-logos/CAMUNDA-MIT.txt +21 -0
  27. package/packages/visualizer/src/components/spec-logos/FILE-ICONS-ISC.txt +13 -0
  28. package/packages/visualizer/src/components/spec-logos/README.md +23 -0
  29. package/packages/visualizer/src/components/spec-logos/SIMPLE-ICONS-CC0.txt +30 -0
  30. package/packages/visualizer/src/components/spec-logos/alloy.svg +1 -0
  31. package/packages/visualizer/src/components/spec-logos/dmn.svg +3 -0
  32. package/packages/visualizer/src/components/spec-logos/gherkin.svg +1 -0
  33. package/packages/visualizer/src/components/spec-logos/openslo.svg +63 -0
  34. package/packages/visualizer/src/components/spec-logos/quint.svg +1 -0
  35. package/packages/visualizer/src/evidence/artifacts.tsx +263 -110
  36. package/packages/visualizer/src/evidence/chart-source.tsx +18 -0
  37. package/packages/visualizer/src/evidence/check-detail.tsx +283 -139
  38. package/packages/visualizer/src/evidence/decision-table.tsx +35 -26
  39. package/packages/visualizer/src/evidence/document-page.tsx +51 -29
  40. package/packages/visualizer/src/evidence/feature-page.tsx +121 -53
  41. package/packages/visualizer/src/evidence/marks.tsx +112 -79
  42. package/packages/visualizer/src/evidence/model-page.tsx +77 -46
  43. package/packages/visualizer/src/evidence/model-trace.tsx +55 -35
  44. package/packages/visualizer/src/evidence/overview.tsx +137 -69
  45. package/packages/visualizer/src/evidence/recording-page.tsx +70 -31
  46. package/packages/visualizer/src/evidence/recording.tsx +135 -68
  47. package/packages/visualizer/src/evidence/snapshot.tsx +99 -43
  48. package/packages/visualizer/src/evidence/source-text.tsx +33 -0
  49. package/packages/visualizer/src/evidence/sources-panel.tsx +138 -144
  50. package/packages/visualizer/src/evidence/spec-header.tsx +52 -36
  51. package/packages/visualizer/src/evidence/spec-index.tsx +61 -32
  52. package/packages/visualizer/src/evidence/spec-page.tsx +56 -25
  53. package/packages/visualizer/src/evidence/test-filter.tsx +38 -14
  54. package/packages/visualizer/src/explorer/Explorer.tsx +688 -261
  55. package/packages/visualizer/src/explorer/ExplorerIndex.tsx +136 -68
  56. package/packages/visualizer/src/explorer/RunsView.tsx +307 -94
  57. package/packages/visualizer/src/explorer/TestDetail.tsx +393 -118
  58. package/packages/visualizer/src/explorer/parts.tsx +35 -13
  59. package/packages/visualizer/src/find/Finder.tsx +106 -90
  60. package/packages/visualizer/src/find/open.ts +17 -20
  61. package/packages/visualizer/src/live/test-progress.tsx +60 -22
  62. package/packages/visualizer/src/looks/Stage.tsx +223 -110
  63. package/packages/visualizer/src/main.tsx +9 -11
  64. package/packages/visualizer/src/map/ChartMap.tsx +125 -69
  65. package/packages/visualizer/src/map/EventEdge.tsx +59 -37
  66. package/packages/visualizer/src/map/map.module.css +12 -546
  67. package/packages/visualizer/src/map/nodes.tsx +124 -70
  68. package/packages/visualizer/src/reachable.test.ts +3 -2
  69. package/packages/visualizer/src/spec-kind.ts +2 -2
  70. package/packages/visualizer/src/ui/grammars/ALLOY-MIT.txt +21 -0
  71. package/packages/visualizer/src/ui/grammars/QUINT-APACHE-2.0.txt +206 -0
  72. package/packages/visualizer/src/ui/grammars/README.md +14 -0
  73. package/packages/visualizer/src/ui/grammars/alloy.json +71 -0
  74. package/packages/visualizer/src/ui/grammars/quint.json +152 -0
  75. package/packages/visualizer/src/ui/highlight.ts +50 -0
  76. package/packages/visualizer/src/ui/theme.tsx +109 -0
  77. package/packages/visualizer/src/views/ChartPage.tsx +46 -26
  78. package/packages/visualizer/src/views/ChartTests.tsx +59 -22
  79. package/packages/visualizer/src/views/ProductPage.tsx +16 -15
  80. package/packages/visualizer/src/views/SidePanel.tsx +241 -131
  81. package/packages/visualizer/src/app/gazetteer.module.css +0 -107
  82. package/packages/visualizer/src/components/ActivityIndicator.module.css +0 -4
  83. package/packages/visualizer/src/components/DataIssues.module.css +0 -136
  84. package/packages/visualizer/src/components/Header.module.css +0 -114
  85. package/packages/visualizer/src/components/Legend.module.css +0 -84
  86. package/packages/visualizer/src/components/Screen.module.css +0 -312
  87. package/packages/visualizer/src/components/Status.module.css +0 -116
  88. package/packages/visualizer/src/components/icons.tsx +0 -156
  89. package/packages/visualizer/src/evidence/artifacts.module.css +0 -213
  90. package/packages/visualizer/src/evidence/evidence.module.css +0 -907
  91. package/packages/visualizer/src/evidence/recording.module.css +0 -460
  92. package/packages/visualizer/src/evidence/sources.module.css +0 -168
  93. package/packages/visualizer/src/evidence/spec-snapshot.tsx +0 -26
  94. package/packages/visualizer/src/explorer/explorer.module.css +0 -1238
  95. package/packages/visualizer/src/find/Finder.module.css +0 -168
  96. package/packages/visualizer/src/looks/stage.module.css +0 -300
  97. package/packages/visualizer/src/styles/global.css +0 -105
  98. package/packages/visualizer/src/styles/tokens.css +0 -80
  99. package/packages/visualizer/src/views/views.module.css +0 -546
@@ -0,0 +1,18 @@
1
+ import { Alert, Button, Text } from '@mantine/core';
2
+ import { useQuery } from '@tanstack/react-query';
3
+ import { keys, specKey, useAdapter } from '../data/queries';
4
+ import { SourceText } from './source-text';
5
+
6
+ /** Reuse the chart source already held by the viewer's spec query, including live updates. */
7
+ export function ChartSource({ file }: { file: string }) {
8
+ const adapter = useAdapter();
9
+ const source = useQuery({ queryKey: keys.specFile(specKey(adapter), file), queryFn: () => adapter.specFile(file), retry: false });
10
+ if (source.isPending) return <Text role="status">Loading source…</Text>;
11
+ if (source.isError)
12
+ return (
13
+ <Alert color="red" title="Source unavailable">
14
+ {source.error.message} <Button onClick={() => void source.refetch()}>Try again</Button>
15
+ </Alert>
16
+ );
17
+ return <SourceText file={file} text={source.data} />;
18
+ }
@@ -1,27 +1,27 @@
1
1
  import { recordingPaths } from '@crvouga/atlas/reports/evidence-types';
2
2
  import { checkHistory, type CheckSlot, type HistoryEntry } from '@crvouga/atlas/reports/evidence-view';
3
+ import { flattenFrames } from '@crvouga/atlas/reports/frames';
3
4
  import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
4
5
  import type { EvidenceResult } from '@crvouga/atlas/reports/layer-merge';
5
- import { flattenFrames } from '@crvouga/atlas/reports/frames';
6
6
  import type { ReportStep, RunReport } from '@crvouga/atlas/reports/report-types';
7
+ import { Accordion, Alert, Anchor, Box, Button, Code, Flex, Group, List, Stack, Table, Text, Title } from '@mantine/core';
7
8
  import { useQuery } from '@tanstack/react-query';
8
9
  import { useMemo, useState, type ReactNode } from 'react';
9
-
10
+ import { TestProgress, useTestProgress } from '../live/test-progress';
10
11
  import { reportJson } from './api';
11
12
  import { ResultEvidence } from './artifacts';
12
13
  import { useEvidence } from './evidence-context';
13
- import styles from './evidence.module.css';
14
14
  import { ago, duration, plural, statusWord, useNow } from './format';
15
15
  import type { FeatureExamples, FeatureStep } from './gherkin';
16
16
  import { Bounded, Origin, StatusMark } from './marks';
17
17
  import { ModelTrace } from './model-trace';
18
18
  import { recordedModelStates } from './quint';
19
- import { TestProgress, useTestProgress } from '../live/test-progress';
20
-
21
19
  /** What the address says is open: a check, and optionally one particular layer's record of it. */
22
- export type CheckSelection = { readonly check?: string; readonly layer?: string };
20
+ export type CheckSelection = {
21
+ readonly check?: string;
22
+ readonly layer?: string;
23
+ };
23
24
  export type SelectCheck = (patch: { check?: string | null; layer?: string | null; view?: 'recording' | null }) => void;
24
-
25
25
  /** The recorded timeline of one result, read only when someone opens it. */
26
26
  export function useRecordedReport(entry: HistoryEntry | null, enabled = true) {
27
27
  const address = entry?.layer.recording ?? null;
@@ -36,10 +36,8 @@ export function useRecordedReport(entry: HistoryEntry | null, enabled = true) {
36
36
  });
37
37
  return { paths, report };
38
38
  }
39
-
40
39
  /** One check in a list: its outcome at a glance, and everything recorded for it when opened. */
41
40
  const ENV_RANK = { passed: 0, 'not-run': 1, running: 2, failed: 3 } as const;
42
-
43
41
  /** One mark per client when the same check was recorded on more than one. */
44
42
  function environmentMarks(slot: CheckSlot) {
45
43
  const seen = new Map<string, CheckSlot['status']>();
@@ -51,147 +49,216 @@ function environmentMarks(slot: CheckSlot) {
51
49
  if (seen.size < 2) return [];
52
50
  return [...seen].map(([id, status]) => ({ id, status }));
53
51
  }
54
-
55
52
  function outcomeName(outcome: EvidenceResult) {
56
53
  const environment = outcome.layer.environment ? environmentLabel(outcome.layer.environment) : '';
57
- const parameters = Object.entries(outcome.parameters).map(([name, value]) => `${name}: ${value}`).join(', ');
54
+ const parameters = Object.entries(outcome.parameters)
55
+ .map(([name, value]) => `${name}: ${value}`)
56
+ .join(', ');
58
57
  return [environment, parameters].filter(Boolean).join(' · ') || outcome.layer.producer;
59
58
  }
60
-
61
- export function ResultRow({ label, slot, open, onToggle, children }: { label: string; slot: CheckSlot | null; open: boolean; onToggle: () => void; children: ReactNode }) {
59
+ export function ResultRow({
60
+ label,
61
+ slot,
62
+ open,
63
+ onToggle,
64
+ children
65
+ }: {
66
+ label: string;
67
+ slot: CheckSlot | null;
68
+ open: boolean;
69
+ onToggle: () => void;
70
+ children: ReactNode;
71
+ }) {
62
72
  const now = useNow();
63
73
  const progress = useTestProgress(slot);
64
74
  const current = progress.tests.find(({ run, check }) => run.status === 'running' && check.status === 'running');
65
75
  const interrupted = !current && progress.tests.find(({ run, check }) => run.status === 'interrupted' && check.status === 'not-run');
66
76
  const newest = slot?.outcomes[0];
67
77
  const marks = slot && !current ? environmentMarks(slot) : [];
68
- const failure = slot?.status === 'failed' ? slot.outcomes.find((outcome) => outcome.row.status === 'failed')?.row.detail.split('\n')[0] : '';
78
+ const failure =
79
+ slot?.status === 'failed' ? slot.outcomes.find((outcome) => outcome.row.status === 'failed')?.row.detail.split('\n')[0] : '';
69
80
  return (
70
- <li className={styles.result} data-status={slot?.status ?? 'none'} data-open={open || undefined}>
71
- <button type="button" className={styles.resultHead} aria-expanded={open} onClick={onToggle}>
72
- <StatusMark status={slot?.status ?? 'none'} />
73
- <span className={styles.resultName}>
74
- {label}
75
- {marks.length > 0 && (
76
- <span className={styles.envMarks}>
77
- {marks.map((mark) => (
78
- <span key={mark.id || 'unspecified'} data-status={mark.status}>{environmentLabel(mark.id || null)}</span>
79
- ))}
80
- </span>
81
- )}
82
- </span>
83
- {slot?.live && <span className={styles.liveTag}>Running now</span>}
84
- {interrupted && <span className={styles.liveTag}>Interrupted</span>}
85
- <span className={styles.resultAside}>
86
- {current ? duration(current.check.durationMs ?? null) || 'In progress' : interrupted ? 'No completed result' : newest ? [newest.row.evidence?.attachments ? plural(newest.row.evidence.attachments, 'attachment', 'attachments') : '', duration(newest.row.durationMs), slot.live ? '' : ago(slot.latestAtMs, now)].filter(Boolean).join(' · ') : 'No result'}
87
- </span>
88
- <span className={styles.chevron} aria-hidden="true" />
89
- </button>
90
- {!open && current && <p className={styles.excerpt} data-progress="true" role="status">{current.check.detail || current.check.steps?.at(-1)?.name || 'Test in progress…'}{progress.error ? ` · ${progress.error}` : ''}</p>}
91
- {!open && interrupted && <p className={styles.excerpt} data-progress="true" role="status">{interrupted.run.reason ?? 'The test run was interrupted.'}</p>}
92
- {!open && !current && failure && <p className={styles.excerpt}>{failure}</p>}
93
- {open && <div className={styles.resultBody}>{children}</div>}
94
- </li>
81
+ <List.Item data-result-row data-status={slot?.status ?? 'none'} data-open={open || undefined}>
82
+ <Accordion variant="separated" value={open ? 'result' : null} onChange={onToggle}>
83
+ <Accordion.Item value="result">
84
+ <Accordion.Control>
85
+ <Group gap="sm" wrap="nowrap">
86
+ <StatusMark status={slot?.status ?? 'none'} />
87
+ <Text data-result-label span inherit>
88
+ {label}
89
+ {marks.length > 0 && (
90
+ <Flex component="span" display="inline-flex" gap="xs" align="center" wrap="wrap">
91
+ {marks.map((mark) => (
92
+ <Text key={mark.id || 'unspecified'} data-status={mark.status} span inherit>
93
+ {environmentLabel(mark.id || null)}
94
+ </Text>
95
+ ))}
96
+ </Flex>
97
+ )}
98
+ </Text>
99
+ {slot?.live && (
100
+ <Text span inherit>
101
+ Running now
102
+ </Text>
103
+ )}
104
+ {interrupted && (
105
+ <Text span inherit>
106
+ Interrupted
107
+ </Text>
108
+ )}
109
+ <Text span inherit c="dimmed">
110
+ {current
111
+ ? duration(current.check.durationMs ?? null) || 'In progress'
112
+ : interrupted
113
+ ? 'No completed result'
114
+ : newest
115
+ ? [
116
+ newest.row.evidence?.attachments ? plural(newest.row.evidence.attachments, 'attachment', 'attachments') : '',
117
+ duration(newest.row.durationMs),
118
+ slot.live ? '' : ago(slot.latestAtMs, now)
119
+ ]
120
+ .filter(Boolean)
121
+ .join(' · ')
122
+ : 'No result'}
123
+ </Text>
124
+ <Text aria-hidden="true" span />
125
+ </Group>
126
+ </Accordion.Control>
127
+ <Accordion.Panel>{open && children}</Accordion.Panel>
128
+ </Accordion.Item>
129
+ </Accordion>
130
+ {!open && current && (
131
+ <Text component="p" data-progress="true" role="status" c="dimmed">
132
+ {current.check.detail || current.check.steps?.at(-1)?.name || 'Test in progress…'}
133
+ {progress.error ? ` · ${progress.error}` : ''}
134
+ </Text>
135
+ )}
136
+ {!open && interrupted && (
137
+ <Text component="p" data-progress="true" role="status" c="dimmed">
138
+ {interrupted.run.reason ?? 'The test run was interrupted.'}
139
+ </Text>
140
+ )}
141
+ {!open && !current && failure && (
142
+ <Text component="p" c="red.7">
143
+ {failure}
144
+ </Text>
145
+ )}
146
+ </List.Item>
95
147
  );
96
148
  }
97
-
98
149
  const KEYWORD = /^(Given|When|Then|And|But|\*)\s+/;
99
-
100
150
  function StepLine({ title }: { title: string }) {
101
151
  const keyword = KEYWORD.exec(title)?.[1];
102
152
  return keyword ? (
103
- <span className={styles.stepText}>
104
- <span className={styles.keyword}>{keyword}</span> {title.slice(keyword.length + 1)}
105
- </span>
153
+ <Text span inherit>
154
+ <Text span inherit>
155
+ {keyword}
156
+ </Text>{' '}
157
+ {title.slice(keyword.length + 1)}
158
+ </Text>
106
159
  ) : (
107
- <span className={styles.stepText}>{title}</span>
160
+ <Text span inherit>
161
+ {title}
162
+ </Text>
108
163
  );
109
164
  }
110
-
111
165
  /** The steps a test recorded, each with what happened to it. */
112
166
  export function RecordedSteps({ steps, nested = false }: { steps: readonly ReportStep[]; nested?: boolean }) {
113
167
  return (
114
- <ol className={styles.steps} data-nested={nested || undefined}>
168
+ <List data-nested={nested || undefined} spacing="xs" listStyleType="none" type="ordered">
115
169
  {steps.map((step) => (
116
- <li key={step.index} data-status={step.status}>
117
- <div className={styles.stepRow}>
170
+ <List.Item key={step.index} data-status={step.status}>
171
+ <Flex gap="xs" align="center" wrap="wrap">
118
172
  <StatusMark status={step.status} size={14} />
119
173
  <StepLine title={step.title} />
120
- <span className={styles.stepTime}>{duration(step.durationMs)}</span>
121
- </div>
122
- {step.error && !step.steps.some((inner) => inner.status === 'failed' && inner.error) && <pre className={styles.error}>{step.error}</pre>}
174
+ <Text span inherit c="dimmed">
175
+ {duration(step.durationMs)}
176
+ </Text>
177
+ </Flex>
178
+ {step.error && !step.steps.some((inner) => inner.status === 'failed' && inner.error) && (
179
+ <Code block c="red.7">
180
+ {step.error}
181
+ </Code>
182
+ )}
123
183
  {step.steps.length > 0 && (
124
- <details className={styles.actions} open={step.status === 'failed'}>
125
- <summary>{plural(step.steps.length, 'recorded action', 'recorded actions')}</summary>
126
- <RecordedSteps steps={step.steps} nested />
127
- </details>
184
+ <Accordion variant="separated" my="sm" defaultValue={step.status === 'failed' ? 'content' : null}>
185
+ <Accordion.Item value="content">
186
+ <Accordion.Control>{plural(step.steps.length, 'recorded action', 'recorded actions')}</Accordion.Control>
187
+ <Accordion.Panel>
188
+ <RecordedSteps steps={step.steps} nested />
189
+ </Accordion.Panel>
190
+ </Accordion.Item>
191
+ </Accordion>
128
192
  )}
129
- </li>
193
+ </List.Item>
130
194
  ))}
131
- </ol>
195
+ </List>
132
196
  );
133
197
  }
134
-
135
198
  /** A scenario as written, for when nothing has been recorded against it. */
136
199
  export function PlainSteps({ steps, examples = [] }: { steps: readonly FeatureStep[]; examples?: readonly FeatureExamples[] }) {
137
200
  return (
138
201
  <>
139
- <ol className={styles.steps}>
202
+ <List spacing="xs" listStyleType="none" type="ordered">
140
203
  {steps.map((step, index) => (
141
- <li key={index}>
142
- <div className={styles.stepRow}>
143
- <span className={styles.stepText}>
144
- <span className={styles.keyword}>{step.keyword}</span> {step.text}
145
- </span>
146
- </div>
147
- {step.docString !== null && <pre className={styles.pre}>{step.docString}</pre>}
204
+ <List.Item key={index}>
205
+ <Flex gap="xs" align="center" wrap="wrap">
206
+ <Text span inherit>
207
+ <Text span inherit>
208
+ {step.keyword}
209
+ </Text>{' '}
210
+ {step.text}
211
+ </Text>
212
+ </Flex>
213
+ {step.docString !== null && <Code block>{step.docString}</Code>}
148
214
  {step.table.length > 0 && <Cells rows={step.table} />}
149
- </li>
215
+ </List.Item>
150
216
  ))}
151
- </ol>
217
+ </List>
152
218
  {examples.map((example, index) => (
153
- <div key={index} className={styles.examples}>
154
- <p className={styles.aside}>Examples{example.name ? `: ${example.name}` : ''}</p>
219
+ <Stack key={index} gap="sm" miw={0}>
220
+ <Text component="div" c="dimmed">
221
+ Examples{example.name ? `: ${example.name}` : ''}
222
+ </Text>
155
223
  <Cells header={example.header} rows={example.rows} />
156
- </div>
224
+ </Stack>
157
225
  ))}
158
226
  </>
159
227
  );
160
228
  }
161
-
162
229
  function Cells({ header, rows }: { header?: readonly string[]; rows: readonly (readonly string[])[] }) {
163
230
  return (
164
- <div className={styles.tableScroll}>
165
- <table className={styles.table}>
231
+ <Table.ScrollContainer minWidth={600}>
232
+ <Table>
166
233
  {header && (
167
- <thead>
168
- <tr>
234
+ <Table.Thead>
235
+ <Table.Tr>
169
236
  {header.map((cell, index) => (
170
- <th key={index} scope="col">{cell}</th>
237
+ <Table.Th key={index} scope="col">
238
+ {cell}
239
+ </Table.Th>
171
240
  ))}
172
- </tr>
173
- </thead>
241
+ </Table.Tr>
242
+ </Table.Thead>
174
243
  )}
175
- <tbody>
244
+ <Table.Tbody>
176
245
  {rows.map((row, index) => (
177
- <tr key={index}>
246
+ <Table.Tr key={index}>
178
247
  {row.map((cell, at) => (
179
- <td key={at}>{cell}</td>
248
+ <Table.Td key={at}>{cell}</Table.Td>
180
249
  ))}
181
- </tr>
250
+ </Table.Tr>
182
251
  ))}
183
- </tbody>
184
- </table>
185
- </div>
252
+ </Table.Tbody>
253
+ </Table>
254
+ </Table.ScrollContainer>
186
255
  );
187
256
  }
188
-
189
257
  function qualification(warning: string | null) {
190
258
  // The origin line already says a checkout had local changes or an unknown origin.
191
259
  if (!warning || warning.includes('modified checkout') || warning.includes('origin was not captured')) return null;
192
260
  return warning;
193
261
  }
194
-
195
262
  type Props = {
196
263
  slot: CheckSlot;
197
264
  /** The layer whose record to read. Without it, the result that won the combined report. */
@@ -202,16 +269,35 @@ type Props = {
202
269
  /** The check as written, shown until a recording is read or when there is none. */
203
270
  written?: ReactNode;
204
271
  };
205
-
206
272
  /** Everything recorded for one check: the chosen result, how it went, and every other run of it. */
207
273
  export function CheckDetail(props: Props) {
208
274
  const progress = useTestProgress(props.slot);
209
- const inProgress = progress.tests.some(({ run, check }) => check.status === 'running' && run.status === 'running' || check.status === 'not-run' && run.status === 'interrupted');
210
- return <><TestProgress {...progress} />{props.slot.outcomes.length > 0
211
- ? inProgress && !props.layerId ? <details className={styles.actions}><summary>Previous recorded result</summary><RecordedCheckDetail {...props} /></details> : <RecordedCheckDetail {...props} />
212
- : props.written}</>;
275
+ const inProgress = progress.tests.some(
276
+ ({ run, check }) =>
277
+ (check.status === 'running' && run.status === 'running') || (check.status === 'not-run' && run.status === 'interrupted')
278
+ );
279
+ return (
280
+ <>
281
+ <TestProgress {...progress} />
282
+ {props.slot.outcomes.length > 0 ? (
283
+ inProgress && !props.layerId ? (
284
+ <Accordion variant="separated" my="sm">
285
+ <Accordion.Item value="content">
286
+ <Accordion.Control>Previous recorded result</Accordion.Control>
287
+ <Accordion.Panel>
288
+ <RecordedCheckDetail {...props} />
289
+ </Accordion.Panel>
290
+ </Accordion.Item>
291
+ </Accordion>
292
+ ) : (
293
+ <RecordedCheckDetail {...props} />
294
+ )
295
+ ) : (
296
+ props.written
297
+ )}
298
+ </>
299
+ );
213
300
  }
214
-
215
301
  function RecordedCheckDetail({ slot, layerId, select, reads, written }: Props) {
216
302
  const { evidence, backends } = useEvidence();
217
303
  const now = useNow();
@@ -228,79 +314,137 @@ function RecordedCheckDetail({ slot, layerId, select, reads, written }: Props) {
228
314
  const states = reads === 'trace' && report.data ? recordedModelStates(report.data.steps).map((entry) => entry.state) : [];
229
315
  const stepsShown = report.data !== undefined && report.data.steps.length > 0 && (reads === 'steps' || states.length === 0);
230
316
  // The failed step carries its own message when the steps are on screen.
231
- const failure = chosen.row.status === 'failed' && !(stepsShown && report.data?.steps.some((step) => step.error)) ? (report.data?.meta.error ?? chosen.row.detail) : '';
232
-
317
+ const failure =
318
+ chosen.row.status === 'failed' && !(stepsShown && report.data?.steps.some((step) => step.error))
319
+ ? (report.data?.meta.error ?? chosen.row.detail)
320
+ : '';
233
321
  return (
234
- <div className={styles.detail}>
322
+ <Stack gap="sm" miw={0}>
235
323
  {slot.outcomes.length > 1 && !earlier && (
236
- <div className={styles.variants} role="group" aria-label="Environments">
324
+ <Flex role="group" aria-label="Environments" gap="xs" align="center" wrap="wrap">
237
325
  {slot.outcomes.map((outcome, index) => (
238
- <button key={outcome.key} type="button" aria-pressed={index === variant} onClick={() => setVariant(index)}>
326
+ <Button key={outcome.key} type="button" aria-pressed={index === variant} onClick={() => setVariant(index)} variant="light">
239
327
  <StatusMark status={outcome.row.status} size={12} />
240
328
  {outcomeName(outcome)}
241
- </button>
329
+ </Button>
242
330
  ))}
243
- </div>
331
+ </Flex>
244
332
  )}
245
- <p className={styles.detailMeta}>
246
- <strong data-status={chosen.row.status}>{statusWord(chosen.row.status)}</strong>
247
- {chosen.row.durationMs !== null && <span>{duration(chosen.row.durationMs)}</span>}
333
+ <Flex gap="xs" align="center" wrap="wrap" c="dimmed">
334
+ <Text data-status={chosen.row.status} span inherit fw={600}>
335
+ {statusWord(chosen.row.status)}
336
+ </Text>
337
+ {chosen.row.durationMs !== null && (
338
+ <Text span inherit>
339
+ {duration(chosen.row.durationMs)}
340
+ </Text>
341
+ )}
248
342
  <Origin layer={chosen.layer} backends={backends} now={now} />
249
- </p>
250
- {note && <p className={styles.note}>{note}</p>}
251
- {failure && <pre className={styles.error}>{failure}</pre>}
252
- {report.isLoading && <p className={styles.aside} role="status">Loading what was recorded…</p>}
343
+ </Flex>
344
+ {note && (
345
+ <Alert color={'blue'} my="sm">
346
+ {note}
347
+ </Alert>
348
+ )}
349
+ {failure && (
350
+ <Code block c="red.7">
351
+ {failure}
352
+ </Code>
353
+ )}
354
+ {report.isLoading && (
355
+ <Text role="status" component="div" c="dimmed">
356
+ Loading what was recorded…
357
+ </Text>
358
+ )}
253
359
  {report.isError && (
254
- <p className={styles.notice} role="alert">
360
+ <Alert role="alert" color={'red'} my="sm">
255
361
  The recording could not be read. {report.error.message}{' '}
256
- <button type="button" className={styles.link} onClick={() => void report.refetch()}>Try again</button>
257
- </p>
362
+ <Button type="button" onClick={() => void report.refetch()} variant="subtle">
363
+ Try again
364
+ </Button>
365
+ </Alert>
258
366
  )}
259
367
  {report.data && stepsShown && <RecordedSteps steps={report.data.steps} />}
260
368
  {states.length > 0 && <ModelTrace states={states} />}
261
369
  {!report.data && written}
262
- {!paths && !written && <p className={styles.aside}>This check has an outcome and no recorded timeline.</p>}
263
- {timings.flatMap((entry) => entry.row.measurements.map((measurement, index) => {
264
- const over = measurement.limit !== undefined && measurement.value > measurement.limit;
265
- return (
266
- <p key={`${entry.key}:${index}`} className={styles.timingLine} data-over={over || undefined}>
267
- {measurement.name}: <strong>{Number(measurement.value.toFixed(1))} {measurement.unit}</strong>
268
- {measurement.limit !== undefined && <span> against an allowance of {measurement.limit} {measurement.unit}</span>}
269
- </p>
270
- );
271
- }))}
370
+ {!paths && !written && (
371
+ <Text component="div" c="dimmed">
372
+ This check has an outcome and no recorded timeline.
373
+ </Text>
374
+ )}
375
+ {timings.flatMap((entry) =>
376
+ entry.row.measurements.map((measurement, index) => {
377
+ const over = measurement.limit !== undefined && measurement.value > measurement.limit;
378
+ return (
379
+ <Text key={`${entry.key}:${index}`} data-over={over || undefined} component="div">
380
+ {measurement.name}:{' '}
381
+ <Text span inherit fw={600}>
382
+ {Number(measurement.value.toFixed(1))} {measurement.unit}
383
+ </Text>
384
+ {measurement.limit !== undefined && (
385
+ <Text span inherit>
386
+ {' '}
387
+ against an allowance of {measurement.limit} {measurement.unit}
388
+ </Text>
389
+ )}
390
+ </Text>
391
+ );
392
+ })
393
+ )}
272
394
  <ResultEvidence layer={chosen.layer} row={chosen.row} />
273
395
  {(moments > 0 || chosen.layer.runUrl) && (
274
- <p className={styles.actionsLine}>
396
+ <Flex gap="xs" align="center" wrap="wrap">
275
397
  {moments > 0 && (
276
- <button type="button" className={styles.primary} onClick={() => select({ view: 'recording', check: slot.id, layer: earlier ? chosen.layer.id : null })}>
277
- Open recording <span>{plural(moments, 'moment', 'moments')}</span>
278
- </button>
398
+ <Button
399
+ type="button"
400
+ onClick={() => select({ view: 'recording', check: slot.id, layer: earlier ? chosen.layer.id : null })}
401
+ variant="filled"
402
+ >
403
+ Open recording{' '}
404
+ <Text span inherit>
405
+ {plural(moments, 'moment', 'moments')}
406
+ </Text>
407
+ </Button>
408
+ )}
409
+ {chosen.layer.runUrl && (
410
+ <Anchor href={chosen.layer.runUrl} target="_blank" rel="noreferrer">
411
+ Open the run
412
+ </Anchor>
279
413
  )}
280
- {chosen.layer.runUrl && <a className={styles.link} href={chosen.layer.runUrl} target="_blank" rel="noreferrer">Open the run</a>}
281
- </p>
414
+ </Flex>
282
415
  )}
283
416
  {history.length > 1 && (
284
- <section className={styles.history} aria-label="Other runs of this check">
285
- <h4>Recorded in {plural(history.length, 'run', 'runs')}</h4>
286
- <ul>
417
+ <Box aria-label="Other runs of this check" component="section" mb="lg">
418
+ <Title order={4} mb="sm">
419
+ Recorded in {plural(history.length, 'run', 'runs')}
420
+ </Title>
421
+ <List spacing="xs" listStyleType="none">
287
422
  <Bounded items={history} size={6} more="Earlier runs">
288
423
  {(entry) => {
289
424
  const current = entry.layer.id === chosen.layer.id && entry.row.kind === chosen.row.kind;
290
425
  return (
291
- <li key={`${entry.layer.backend}|${entry.layer.id}|${entry.row.kind}`}>
292
- <button type="button" aria-current={current || undefined} onClick={() => select({ layer: slot.outcomes.some((outcome) => outcome.layer.id === entry.layer.id) ? null : entry.layer.id })}>
426
+ <List.Item key={`${entry.layer.backend}|${entry.layer.id}|${entry.row.kind}`}>
427
+ <Button
428
+ type="button"
429
+ aria-current={current || undefined}
430
+ onClick={() =>
431
+ select({ layer: slot.outcomes.some((outcome) => outcome.layer.id === entry.layer.id) ? null : entry.layer.id })
432
+ }
433
+ variant="light"
434
+ >
293
435
  <StatusMark status={entry.row.status} size={14} />
294
- <span>{statusWord(entry.row.status)}</span>
436
+ <Text span inherit>
437
+ {statusWord(entry.row.status)}
438
+ </Text>
295
439
  <Origin layer={entry.layer} backends={backends} now={now} />
296
- </button>
297
- </li>
440
+ </Button>
441
+ </List.Item>
298
442
  );
299
443
  }}
300
444
  </Bounded>
301
- </ul>
302
- </section>
445
+ </List>
446
+ </Box>
303
447
  )}
304
- </div>
448
+ </Stack>
305
449
  );
306
450
  }