@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
@@ -1,10 +1,7 @@
1
+ import { Alert, Box, Table, Text, Title } from '@mantine/core';
1
2
  import { XMLParser, XMLValidator } from 'fast-xml-parser';
2
-
3
- import styles from './evidence.module.css';
4
-
5
3
  type Xml = Record<string, unknown>;
6
4
  const array = (value: unknown): Xml[] => (Array.isArray(value) ? value : value ? [value] : []) as Xml[];
7
-
8
5
  export function decisionTables(text: string) {
9
6
  if (XMLValidator.validate(text) !== true) throw new Error('Invalid decision-table XML');
10
7
  const doc = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true, parseTagValue: false }).parse(text) as Xml;
@@ -21,47 +18,59 @@ export function decisionTables(text: string) {
21
18
  id: String(table['@_id'] ?? 'Decision table'),
22
19
  policy: String(table['@_hitPolicy'] ?? 'UNIQUE'),
23
20
  inputs: array(table.input).length,
24
- columns: [...array(table.input), ...array(table.output)].map((column) => String(column['@_label'] ?? column['@_name'] ?? (column.inputExpression as Xml | undefined)?.text ?? 'Condition')),
21
+ columns: [...array(table.input), ...array(table.output)].map((column) =>
22
+ String(column['@_label'] ?? column['@_name'] ?? (column.inputExpression as Xml | undefined)?.text ?? 'Condition')
23
+ ),
25
24
  rows: array(table.rule).map((rule) => [...array(rule.inputEntry), ...array(rule.outputEntry)].map((entry) => String(entry.text ?? '')))
26
25
  }));
27
26
  }
28
-
29
27
  /** Each decision as its conditions and the outcomes they lead to, side by side. */
30
28
  export function DecisionTables({ text }: { text: string }) {
31
29
  let tables: ReturnType<typeof decisionTables>;
32
30
  try {
33
31
  tables = decisionTables(text);
34
32
  } catch (error) {
35
- return <p role="alert" className={styles.notice}>This decision table could not be read: {error instanceof Error ? error.message : String(error)}</p>;
33
+ return (
34
+ <Alert role="alert" color={'red'} my="sm">
35
+ This decision table could not be read: {error instanceof Error ? error.message : String(error)}
36
+ </Alert>
37
+ );
36
38
  }
37
39
  return (
38
40
  <>
39
41
  {tables.map((table) => (
40
- <section key={table.id} className={styles.block}>
41
- <h2 className={styles.blockTitle}>
42
- {table.id} <span className={styles.aside}>Hit policy {table.policy.toLowerCase()}</span>
43
- </h2>
44
- <div className={styles.tableScroll}>
45
- <table className={styles.table}>
46
- <thead>
47
- <tr>
42
+ <Box key={table.id} component="section" mb="lg">
43
+ <Title order={2} mb="sm">
44
+ {table.id}{' '}
45
+ <Text span inherit c="dimmed">
46
+ Hit policy {table.policy.toLowerCase()}
47
+ </Text>
48
+ </Title>
49
+ <Table.ScrollContainer minWidth={600}>
50
+ <Table>
51
+ <Table.Thead>
52
+ <Table.Tr>
48
53
  {table.columns.map((column, index) => (
49
- <th key={index} scope="col" data-output={index >= table.inputs || undefined}>{column}</th>
54
+ <Table.Th key={index} scope="col" data-output={index >= table.inputs || undefined}>
55
+ {column}
56
+ </Table.Th>
50
57
  ))}
51
- </tr>
52
- </thead>
53
- <tbody>
58
+ </Table.Tr>
59
+ </Table.Thead>
60
+ <Table.Tbody>
54
61
  {table.rows.map((row, index) => (
55
- <tr key={index}>
62
+ <Table.Tr key={index}>
56
63
  {row.map((cell, at) => (
57
- <td key={at} data-output={at >= table.inputs || undefined}>{cell || '—'}</td>
64
+ <Table.Td key={at} data-output={at >= table.inputs || undefined}>
65
+ {cell || '—'}
66
+ </Table.Td>
58
67
  ))}
59
- </tr>
68
+ </Table.Tr>
60
69
  ))}
61
- </tbody>
62
- </table>
63
- </div>
64
- </section>
70
+ </Table.Tbody>
71
+ </Table>
72
+ </Table.ScrollContainer>
73
+ </Box>
65
74
  ))}
66
75
  </>
67
76
  );
@@ -1,44 +1,66 @@
1
1
  import type { SpecEvidence } from '@crvouga/atlas/reports/evidence-view';
2
-
2
+ import { Box, Container, List, Title } from '@mantine/core';
3
3
  import type { SpecKind } from '../spec-kind';
4
4
  import { CheckDetail, ResultRow, type CheckSelection, type SelectCheck } from './check-detail';
5
5
  import { DecisionTables } from './decision-table';
6
- import styles from './evidence.module.css';
7
6
  import { Bounded } from './marks';
8
- import { SourceText, SpecHeader } from './spec-header';
9
- import { SpecSnapshot } from './spec-snapshot';
7
+ import { SpecHeader } from './spec-header';
8
+ import { SourceText } from './source-text';
10
9
  import { TestFilter, useRunningFilter } from './test-filter';
11
-
12
- type Props = { file: string; text: string; kind: SpecKind; entry: SpecEvidence | undefined; selection: CheckSelection; select: SelectCheck };
13
-
10
+ type Props = {
11
+ file: string;
12
+ text: string;
13
+ kind: SpecKind;
14
+ entry: SpecEvidence | undefined;
15
+ selection: CheckSelection;
16
+ select: SelectCheck;
17
+ };
14
18
  /** A decision table, structure or objective: what it says, and each check recorded against it. */
15
19
  export function DocumentPage({ file, text, kind, entry, selection, select }: Props) {
16
20
  const slots = entry?.slots ?? [];
17
21
  const runningOnly = useRunningFilter();
18
- const visible = slots.filter(slot => !runningOnly || slot.status === 'running');
22
+ const visible = slots.filter((slot) => !runningOnly || slot.status === 'running');
19
23
  const toggle = (id: string) => select({ check: selection.check === id ? null : id, layer: null });
20
- const name = file.split('/').at(-1)!.replace(/\.[^.]+$/, '').replace(/\.openslo$/, '');
24
+ const name = file
25
+ .split('/')
26
+ .at(-1)!
27
+ .replace(/\.[^.]+$/, '')
28
+ .replace(/\.openslo$/, '');
21
29
  return (
22
- <article className={styles.page}>
30
+ <Container component="article" size="xl" w="100%" py="xl">
23
31
  <SpecHeader title={name} file={file} kind={kind} entry={entry} />
24
- <SpecSnapshot title={name} text={text} kind={kind.one} />
25
- <TestFilter slots={slots} />
26
- {visible.length > 0 && (
27
- <section className={styles.block}>
28
- <h2 className={styles.blockTitle}>Checks</h2>
29
- <ul className={styles.results}>
30
- <Bounded items={visible} size={40} more="More checks">
31
- {(slot) => (
32
- <ResultRow key={slot.key} label={slot.title} slot={slot} open={selection.check === slot.id} onToggle={() => toggle(slot.id)}>
33
- <CheckDetail slot={slot} layerId={selection.check === slot.id ? selection.layer : undefined} select={(patch) => select({ check: slot.id, ...patch })} reads="steps" />
34
- </ResultRow>
35
- )}
36
- </Bounded>
37
- </ul>
38
- </section>
39
- )}
40
- {kind.id === 'dmn' && <DecisionTables text={text} />}
41
- <SourceText text={text} open={kind.id !== 'dmn'} />
42
- </article>
32
+ <SourceText text={text} file={file} withResults />
33
+ <Box component="section" id="spec-results" aria-label="Recorded results" tabIndex={-1}>
34
+ <TestFilter slots={slots} />
35
+ {visible.length > 0 && (
36
+ <Box component="section" mb="lg">
37
+ <Title order={2} mb="sm">
38
+ Checks
39
+ </Title>
40
+ <List spacing="xs" listStyleType="none">
41
+ <Bounded items={visible} size={40} more="More checks">
42
+ {(slot) => (
43
+ <ResultRow
44
+ key={slot.key}
45
+ label={slot.title}
46
+ slot={slot}
47
+ open={selection.check === slot.id}
48
+ onToggle={() => toggle(slot.id)}
49
+ >
50
+ <CheckDetail
51
+ slot={slot}
52
+ layerId={selection.check === slot.id ? selection.layer : undefined}
53
+ select={(patch) => select({ check: slot.id, ...patch })}
54
+ reads="steps"
55
+ />
56
+ </ResultRow>
57
+ )}
58
+ </Bounded>
59
+ </List>
60
+ </Box>
61
+ )}
62
+ {kind.id === 'dmn' && <DecisionTables text={text} />}
63
+ </Box>
64
+ </Container>
43
65
  );
44
66
  }
@@ -1,92 +1,160 @@
1
1
  import type { CheckSlot, SpecEvidence } from '@crvouga/atlas/reports/evidence-view';
2
+ import { Alert, Box, Container, List, Stack, Text, Title } from '@mantine/core';
2
3
  import { useMemo } from 'react';
3
-
4
4
  import type { SpecKind } from '../spec-kind';
5
5
  import { CheckDetail, PlainSteps, ResultRow, type CheckSelection, type SelectCheck } from './check-detail';
6
- import styles from './evidence.module.css';
7
6
  import { featureModel, scenarioLabel, slotsByScenario, type FeatureModel, type FeatureScenario } from './gherkin';
8
7
  import { Bounded } from './marks';
9
- import { SourceText, SpecHeader } from './spec-header';
10
- import { SpecSnapshot } from './spec-snapshot';
8
+ import { SpecHeader } from './spec-header';
9
+ import { SourceText } from './source-text';
11
10
  import { TestFilter, useRunningFilter } from './test-filter';
12
-
13
- type Props = { file: string; text: string; kind: SpecKind; entry: SpecEvidence | undefined; selection: CheckSelection; select: SelectCheck };
14
-
15
- function parse(text: string): { feature: FeatureModel | null; error: string | null } {
11
+ type Props = {
12
+ file: string;
13
+ text: string;
14
+ kind: SpecKind;
15
+ entry: SpecEvidence | undefined;
16
+ selection: CheckSelection;
17
+ select: SelectCheck;
18
+ };
19
+ function parse(text: string): {
20
+ feature: FeatureModel | null;
21
+ error: string | null;
22
+ } {
16
23
  try {
17
24
  return { feature: featureModel(text), error: null };
18
25
  } catch (error) {
19
26
  return { feature: null, error: error instanceof Error ? error.message : String(error) };
20
27
  }
21
28
  }
22
-
23
29
  /** A feature read as its rules and scenarios, each scenario with what was recorded when it ran. */
24
30
  export function FeaturePage({ file, text, kind, entry, selection, select }: Props) {
25
31
  const { feature, error } = useMemo(() => parse(text), [text]);
26
32
  const slots = entry?.slots ?? [];
27
33
  const runningOnly = useRunningFilter();
28
- const filed = useMemo(() => (feature ? slotsByScenario(feature, slots) : { byScenario: new Map<string, CheckSlot[]>(), leftOver: slots }), [feature, slots]);
34
+ const filed = useMemo(
35
+ () => (feature ? slotsByScenario(feature, slots) : { byScenario: new Map<string, CheckSlot[]>(), leftOver: slots }),
36
+ [feature, slots]
37
+ );
29
38
  const scenarios = feature?.rules.flatMap((rule) => rule.scenarios.filter((scenario) => !scenario.background)) ?? [];
30
- const planned = scenarios.reduce((count, scenario) => count + Math.max(1, filed.byScenario.get(scenario.name)?.length ?? 0), 0) + filed.leftOver.length;
39
+ const planned =
40
+ scenarios.reduce((count, scenario) => count + Math.max(1, filed.byScenario.get(scenario.name)?.length ?? 0), 0) + filed.leftOver.length;
31
41
  const toggle = (id: string) => select({ check: selection.check === id ? null : id, layer: null });
32
-
33
42
  const rows = (scenario: FeatureScenario) => {
34
43
  const written = <PlainSteps steps={scenario.steps} examples={scenario.examples} />;
35
- const recorded = (filed.byScenario.get(scenario.name) ?? []).filter(slot => !runningOnly || slot.status === 'running');
44
+ const recorded = (filed.byScenario.get(scenario.name) ?? []).filter((slot) => !runningOnly || slot.status === 'running');
36
45
  if (!recorded.length) {
37
46
  if (runningOnly) return null;
38
47
  const id = `scenario:${scenario.name}`;
39
48
  return (
40
49
  <ResultRow key={id} label={scenario.name} slot={null} open={selection.check === id} onToggle={() => toggle(id)}>
41
- <p className={styles.aside}>No completed test report for this scenario yet.</p>
50
+ <Text component="div" c="dimmed">
51
+ No completed test report for this scenario yet.
52
+ </Text>
42
53
  {written}
43
54
  </ResultRow>
44
55
  );
45
56
  }
46
57
  return recorded.map((slot) => (
47
- <ResultRow key={slot.key} label={scenarioLabel(feature!.name, slot.title)} slot={slot} open={selection.check === slot.id} onToggle={() => toggle(slot.id)}>
48
- <CheckDetail slot={slot} layerId={selection.check === slot.id ? selection.layer : undefined} select={(patch) => select({ check: slot.id, ...patch })} reads="steps" written={written} />
58
+ <ResultRow
59
+ key={slot.key}
60
+ label={scenarioLabel(feature!.name, slot.title)}
61
+ slot={slot}
62
+ open={selection.check === slot.id}
63
+ onToggle={() => toggle(slot.id)}
64
+ >
65
+ <CheckDetail
66
+ slot={slot}
67
+ layerId={selection.check === slot.id ? selection.layer : undefined}
68
+ select={(patch) => select({ check: slot.id, ...patch })}
69
+ reads="steps"
70
+ written={written}
71
+ />
49
72
  </ResultRow>
50
73
  ));
51
74
  };
52
-
53
75
  return (
54
- <article className={styles.page}>
55
- <SpecHeader title={feature?.name || file.split('/').at(-1)!} file={file} kind={kind} entry={entry} planned={feature ? planned : undefined}>
56
- {feature?.description && <p className={styles.lead}>{feature.description}</p>}
76
+ <Container component="article" size="xl" w="100%" py="xl">
77
+ <SpecHeader
78
+ title={feature?.name || file.split('/').at(-1)!}
79
+ file={file}
80
+ kind={kind}
81
+ entry={entry}
82
+ planned={feature ? planned : undefined}
83
+ >
84
+ {feature?.description && (
85
+ <Text component="div" c="dimmed">
86
+ {feature.description}
87
+ </Text>
88
+ )}
57
89
  </SpecHeader>
58
- <SpecSnapshot title={feature?.name || file.split('/').at(-1)!} text={text} kind={kind.one} />
59
- <TestFilter slots={slots} />
60
- {error && <p role="alert" className={styles.notice}>This feature could not be parsed: {error}</p>}
61
- {feature?.rules.map((rule, index) => (
62
- <section key={index} className={styles.block}>
63
- {rule.name !== null && <h2 className={styles.blockTitle}>{rule.name}</h2>}
64
- {rule.description && <p className={styles.blockLead}>{rule.description}</p>}
65
- {rule.scenarios.filter((scenario) => scenario.background).map((scenario, at) => (
66
- <div key={at} className={styles.background}>
67
- <p className={styles.aside}>Before each scenario{scenario.name ? `: ${scenario.name}` : ''}</p>
68
- <PlainSteps steps={scenario.steps} />
69
- </div>
70
- ))}
71
- <ul className={styles.results}>{rule.scenarios.filter((scenario) => !scenario.background).map(rows)}</ul>
72
- </section>
73
- ))}
74
- {filed.leftOver.some(slot => !runningOnly || slot.status === 'running') && (
75
- <section className={styles.block}>
76
- <h2 className={styles.blockTitle}>{feature ? 'Other recorded results' : 'Recorded results'}</h2>
77
- {feature && <p className={styles.blockLead}>Recorded against this file under a name no scenario has now.</p>}
78
- <ul className={styles.results}>
79
- <Bounded items={filed.leftOver.filter(slot => !runningOnly || slot.status === 'running')} size={40} more="More results">
80
- {(slot) => (
81
- <ResultRow key={slot.key} label={feature ? scenarioLabel(feature.name, slot.title) : slot.title} slot={slot} open={selection.check === slot.id} onToggle={() => toggle(slot.id)}>
82
- <CheckDetail slot={slot} layerId={selection.check === slot.id ? selection.layer : undefined} select={(patch) => select({ check: slot.id, ...patch })} reads="steps" />
83
- </ResultRow>
84
- )}
85
- </Bounded>
86
- </ul>
87
- </section>
88
- )}
89
- <SourceText text={text} label="Feature source" open={!feature} />
90
- </article>
90
+ <SourceText text={text} file={file} withResults />
91
+ <Box component="section" id="spec-results" aria-label="Recorded results" tabIndex={-1}>
92
+ <TestFilter slots={slots} />
93
+ {error && (
94
+ <Alert role="alert" color={'red'} my="sm">
95
+ This feature could not be parsed: {error}
96
+ </Alert>
97
+ )}
98
+ {feature?.rules.map((rule, index) => (
99
+ <Box key={index} component="section" mb="lg">
100
+ {rule.name !== null && (
101
+ <Title order={2} mb="sm">
102
+ {rule.name}
103
+ </Title>
104
+ )}
105
+ {rule.description && (
106
+ <Text component="div" c="dimmed">
107
+ {rule.description}
108
+ </Text>
109
+ )}
110
+ {rule.scenarios
111
+ .filter((scenario) => scenario.background)
112
+ .map((scenario, at) => (
113
+ <Stack key={at} gap="sm" miw={0}>
114
+ <Text component="div" c="dimmed">
115
+ Before each scenario{scenario.name ? `: ${scenario.name}` : ''}
116
+ </Text>
117
+ <PlainSteps steps={scenario.steps} />
118
+ </Stack>
119
+ ))}
120
+ <List spacing="xs" listStyleType="none">
121
+ {rule.scenarios.filter((scenario) => !scenario.background).map(rows)}
122
+ </List>
123
+ </Box>
124
+ ))}
125
+ {filed.leftOver.some((slot) => !runningOnly || slot.status === 'running') && (
126
+ <Box component="section" mb="lg">
127
+ <Title order={2} mb="sm">
128
+ {feature ? 'Other recorded results' : 'Recorded results'}
129
+ </Title>
130
+ {feature && (
131
+ <Text component="div" c="dimmed">
132
+ Recorded against this file under a name no scenario has now.
133
+ </Text>
134
+ )}
135
+ <List spacing="xs" listStyleType="none">
136
+ <Bounded items={filed.leftOver.filter((slot) => !runningOnly || slot.status === 'running')} size={40} more="More results">
137
+ {(slot) => (
138
+ <ResultRow
139
+ key={slot.key}
140
+ label={feature ? scenarioLabel(feature.name, slot.title) : slot.title}
141
+ slot={slot}
142
+ open={selection.check === slot.id}
143
+ onToggle={() => toggle(slot.id)}
144
+ >
145
+ <CheckDetail
146
+ slot={slot}
147
+ layerId={selection.check === slot.id ? selection.layer : undefined}
148
+ select={(patch) => select({ check: slot.id, ...patch })}
149
+ reads="steps"
150
+ />
151
+ </ResultRow>
152
+ )}
153
+ </Bounded>
154
+ </List>
155
+ </Box>
156
+ )}
157
+ </Box>
158
+ </Container>
91
159
  );
92
160
  }
@@ -1,119 +1,152 @@
1
+ import { IconCheck, IconX, IconCircle } from '@tabler/icons-react';
1
2
  import type { BackendInfo, LayerHeader, StatusCounts } from '@crvouga/atlas/reports/evidence-types';
2
3
  import type { SpecStatus, SpecSummary } from '@crvouga/atlas/reports/evidence-view';
3
4
  import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
5
+ import { Badge, Box, Button, Flex, List, Loader, Progress, Text, ThemeIcon } from '@mantine/core';
4
6
  import { useState, type ReactNode } from 'react';
5
-
6
- import styles from './evidence.module.css';
7
+ import { statusColor } from '../ui/theme';
7
8
  import { ago, exactTime, plural, shortSha, statusWord } from './format';
8
-
9
- /** The outcome as a small mark. Its word is always available to a screen reader. */
9
+ /** A named outcome mark; color is always accompanied by a distinct symbol and accessible text. */
10
10
  export function StatusMark({ status, size = 16 }: { status: SpecStatus; size?: number }) {
11
- const common = { width: size, height: size, viewBox: '0 0 16 16', className: styles.mark, 'data-status': status, role: 'img', 'aria-label': statusWord(status) } as const;
12
- if (status === 'passed') {
13
- return (
14
- <svg {...common}>
15
- <circle cx="8" cy="8" r="7" fill="currentColor" />
16
- <path d="M4.7 8.3l2.2 2.2 4.4-4.7" fill="none" stroke="#fff" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
17
- </svg>
18
- );
19
- }
20
- if (status === 'failed') {
21
- return (
22
- <svg {...common}>
23
- <circle cx="8" cy="8" r="7" fill="currentColor" />
24
- <path d="M5.4 5.4l5.2 5.2M10.6 5.4l-5.2 5.2" fill="none" stroke="#fff" strokeWidth="1.7" strokeLinecap="round" />
25
- </svg>
26
- );
27
- }
28
- if (status === 'running') {
29
- return (
30
- <svg {...common}>
31
- <circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />
32
- <path d="M8 2a6 6 0 0 1 6 6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
33
- </svg>
34
- );
35
- }
11
+ if (status === 'running') return <Loader size={size} role="img" aria-label={statusWord(status)} />;
36
12
  return (
37
- <svg {...common}>
38
- <circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="1.5" strokeDasharray={status === 'none' ? '2.2 2.2' : undefined} />
39
- </svg>
13
+ <ThemeIcon
14
+ size={size}
15
+ radius="xl"
16
+ variant={status === 'none' ? 'outline' : 'light'}
17
+ color={statusColor(status)}
18
+ role="img"
19
+ aria-label={statusWord(status)}
20
+ >
21
+ {status === 'passed' ? (
22
+ <IconCheck size={size} aria-hidden="true" />
23
+ ) : status === 'failed' ? (
24
+ <IconX size={size} aria-hidden="true" />
25
+ ) : (
26
+ <IconCircle size={size} aria-hidden="true" />
27
+ )}
28
+ </ThemeIcon>
40
29
  );
41
30
  }
42
-
43
- /** What was recorded for a spec, short enough for an index row. */
31
+ /** An index row's outcome, with the exact recorded count. */
44
32
  export function Tally({ summary }: { summary: SpecSummary }) {
45
- if (summary.status === 'none') return <span className={styles.tally} data-status="none">No results</span>;
46
- if (summary.counts.failed) return <span className={styles.tally} data-status="failed">{summary.counts.failed} failed</span>;
47
- if (summary.counts.running) return <span className={styles.tally} data-status="running">Running</span>;
48
- if (!summary.counts.passed) return <span className={styles.tally} data-status="not-run">Not run</span>;
49
- return <span className={styles.tally} data-status="passed">{summary.counts.passed === summary.total ? `${summary.total} passed` : `${summary.counts.passed}/${summary.total}`}</span>;
33
+ const label =
34
+ summary.status === 'none'
35
+ ? 'No results'
36
+ : summary.counts.failed
37
+ ? `${summary.counts.failed} failed`
38
+ : summary.counts.running
39
+ ? 'Running'
40
+ : !summary.counts.passed
41
+ ? 'Not run'
42
+ : summary.counts.passed === summary.total
43
+ ? `${summary.total} passed`
44
+ : `${summary.counts.passed}/${summary.total}`;
45
+ return (
46
+ <Badge color={statusColor(summary.status)} size="sm">
47
+ {label}
48
+ </Badge>
49
+ );
50
50
  }
51
-
52
51
  const SEGMENTS = ['failed', 'running', 'passed', 'not-run'] as const;
53
-
54
- /** Proportions of the recorded outcomes. With nothing recorded it is an empty track. */
52
+ /** Recorded outcome proportions. Missing evidence remains an unfilled gray segment. */
55
53
  export function Meter({ counts, missing = 0 }: { counts: StatusCounts; missing?: number }) {
56
54
  const total = SEGMENTS.reduce((sum, status) => sum + counts[status], 0) + missing;
57
55
  return (
58
- <span className={styles.meter} aria-hidden="true">
59
- {total > 0 && SEGMENTS.map((status) => counts[status] > 0 && <span key={status} data-status={status} style={{ flexGrow: counts[status] }} />)}
60
- {total > 0 && missing > 0 && <span data-status="none" style={{ flexGrow: missing }} />}
61
- </span>
56
+ <Progress.Root size="sm" w="100%" miw="4rem" aria-hidden="true">
57
+ {total > 0 &&
58
+ SEGMENTS.map(
59
+ (status) =>
60
+ counts[status] > 0 && <Progress.Section key={status} value={(counts[status] / total) * 100} color={statusColor(status)} />
61
+ )}
62
+ {total > 0 && missing > 0 && <Progress.Section value={(missing / total) * 100} color="gray.3" />}
63
+ </Progress.Root>
62
64
  );
63
65
  }
64
-
65
66
  export function CountLine({ summary, noun, nouns }: { summary: SpecSummary; noun: string; nouns: string }) {
66
- if (summary.status === 'none') return <span>No {nouns} recorded yet</span>;
67
- const parts = [
68
- summary.counts.failed ? <strong key="failed" className={styles.failedText}>{summary.counts.failed} failed</strong> : null,
69
- summary.counts.passed ? <span key="passed">{summary.counts.passed} passed</span> : null,
70
- summary.counts.running ? <span key="running">{summary.counts.running} running</span> : null,
71
- summary.counts['not-run'] ? <span key="not-run">{summary.counts['not-run']} not run</span> : null
72
- ].filter(Boolean);
67
+ if (summary.status === 'none') return <Text span>No {nouns} recorded yet</Text>;
73
68
  return (
74
- <span className={styles.countLine}>
75
- <span>{plural(summary.total, noun, nouns)}</span>
76
- {parts}
77
- </span>
69
+ <Flex component="span" display="inline-flex" wrap="wrap" gap="xs">
70
+ <Text span>{plural(summary.total, noun, nouns)}</Text>
71
+ {SEGMENTS.map(
72
+ (status) =>
73
+ summary.counts[status] > 0 && (
74
+ <Text span key={status} c={statusColor(status)}>
75
+ {summary.counts[status]} {status === 'not-run' ? 'not run' : status}
76
+ </Text>
77
+ )
78
+ )}
79
+ </Flex>
78
80
  );
79
81
  }
80
-
81
82
  export function backendLabel(backends: readonly BackendInfo[], id: string) {
82
83
  return backends.find((backend) => backend.id === id)?.label ?? id;
83
84
  }
84
-
85
- /** Where and when a result was recorded. A modified checkout is stated, not raised as an alarm. */
85
+ /** A result's provenance; incomplete origin data is stated explicitly. */
86
86
  export function Origin({ layer, backends, now }: { layer: LayerHeader; backends: readonly BackendInfo[]; now: number }) {
87
87
  const sha = shortSha(layer.sha);
88
88
  return (
89
- <span className={styles.origin}>
90
- {layer.live && <span className={styles.liveTag}>Running now</span>}
91
- <span>{backendLabel(backends, layer.backend)}</span>
92
- {layer.branch && <span>{layer.branch}</span>}
93
- {layer.environment && <span>{environmentLabel(layer.environment)}</span>}
94
- {sha && <code>{sha}</code>}
95
- {layer.freshness === 'modified' && <span>with local changes</span>}
96
- {layer.freshness === 'unknown' && <span>origin not captured</span>}
97
- {!layer.live && <time dateTime={new Date(layer.recordedAtMs).toISOString()} title={exactTime(layer.recordedAtMs)}>{ago(layer.recordedAtMs, now)}</time>}
89
+ <Flex component="span" display="inline-flex" wrap="wrap" gap="xs" align="center" c="dimmed" fz="xs">
90
+ {layer.live && <Badge size="sm">Running now</Badge>}
91
+ <Text span inherit>
92
+ {backendLabel(backends, layer.backend)}
93
+ </Text>
94
+ {layer.branch && (
95
+ <Text span inherit>
96
+ {layer.branch}
97
+ </Text>
98
+ )}
99
+ {layer.environment && (
100
+ <Text span inherit>
101
+ {environmentLabel(layer.environment)}
102
+ </Text>
103
+ )}
104
+ {sha && <Box component="code">{sha}</Box>}
105
+ {layer.freshness === 'modified' && (
106
+ <Text span inherit>
107
+ with local changes
108
+ </Text>
109
+ )}
110
+ {layer.freshness === 'unknown' && (
111
+ <Text span inherit>
112
+ origin not captured
113
+ </Text>
114
+ )}
115
+ {!layer.live && (
116
+ <time dateTime={new Date(layer.recordedAtMs).toISOString()} title={exactTime(layer.recordedAtMs)}>
117
+ {ago(layer.recordedAtMs, now)}
118
+ </time>
119
+ )}
98
120
  {Object.entries(layer.parameters).map(([name, value]) => (
99
- <span key={name} className={styles.param}>{name}: {value}</span>
121
+ <Text span inherit key={name}>
122
+ {name}: {value}
123
+ </Text>
100
124
  ))}
101
- </span>
125
+ </Flex>
102
126
  );
103
127
  }
104
-
105
- /** A long list in pages, so the page stays light however much has been recorded. */
106
- export function Bounded<T>({ items, size, more, children }: { items: readonly T[]; size: number; more: string; children: (item: T, index: number) => ReactNode }) {
128
+ /** Bound long report lists without losing access to earlier evidence. */
129
+ export function Bounded<T>({
130
+ items,
131
+ size,
132
+ more,
133
+ children
134
+ }: {
135
+ items: readonly T[];
136
+ size: number;
137
+ more: string;
138
+ children: (item: T, index: number) => ReactNode;
139
+ }) {
107
140
  const [shown, setShown] = useState(size);
108
141
  return (
109
142
  <>
110
143
  {items.slice(0, shown).map(children)}
111
144
  {items.length > shown && (
112
- <li className={styles.moreRow}>
113
- <button type="button" className={styles.quiet} onClick={() => setShown(shown + size)}>
145
+ <List.Item>
146
+ <Button variant="subtle" onClick={() => setShown(shown + size)}>
114
147
  {more} ({items.length - shown} more)
115
- </button>
116
- </li>
148
+ </Button>
149
+ </List.Item>
117
150
  )}
118
151
  </>
119
152
  );