@crvouga/atlas 0.10.2 → 0.12.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 (114) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +7 -0
  3. package/dist/reports/src/catalog-health.d.ts +142 -0
  4. package/dist/reports/src/catalog-health.js +257 -0
  5. package/dist/reports/src/test-catalog.d.ts +8 -0
  6. package/dist/reports/src/test-catalog.js +5 -1
  7. package/dist/schema/src/api.d.ts +79 -0
  8. package/dist/schema/src/api.js +50 -0
  9. package/dist/server/src/app.js +9 -0
  10. package/dist/server/src/llms.js +1 -0
  11. package/dist/server/src/openapi.js +18 -2
  12. package/dist/server/src/settings-page.d.ts +4 -4
  13. package/dist/server/src/settings-page.js +17 -78
  14. package/docs/reference/http-api.md +34 -0
  15. package/docs/reference/reports.md +224 -0
  16. package/docs/reference/schema.md +10 -0
  17. package/docs/reference/server.md +4 -4
  18. package/docs/server.md +10 -0
  19. package/docs/ui.md +142 -0
  20. package/docs/workspaces.md +130 -0
  21. package/llms-full.txt +322 -0
  22. package/llms.txt +2 -0
  23. package/package.json +17 -10
  24. package/packages/schema/schemas/catalog-health.schema.json +400 -0
  25. package/packages/server/openapi.json +596 -0
  26. package/packages/visualizer/src/admin/SettingsPage.tsx +456 -0
  27. package/packages/visualizer/src/admin/main.tsx +11 -0
  28. package/packages/visualizer/src/app/AppShell.tsx +62 -38
  29. package/packages/visualizer/src/app/gazetteer.tsx +90 -83
  30. package/packages/visualizer/src/app/router.tsx +33 -15
  31. package/packages/visualizer/src/components/ActivityIndicator.tsx +4 -5
  32. package/packages/visualizer/src/components/DataIssues.tsx +36 -54
  33. package/packages/visualizer/src/components/Header.tsx +197 -125
  34. package/packages/visualizer/src/components/Legend.tsx +13 -41
  35. package/packages/visualizer/src/components/ProjectPicker.tsx +61 -26
  36. package/packages/visualizer/src/components/Screen.tsx +80 -71
  37. package/packages/visualizer/src/components/SpecLogo.tsx +15 -0
  38. package/packages/visualizer/src/components/Status.tsx +20 -34
  39. package/packages/visualizer/src/components/labels.tsx +8 -3
  40. package/packages/visualizer/src/components/spec-logos/APACHE-2.0.txt +206 -0
  41. package/packages/visualizer/src/components/spec-logos/CAMUNDA-MIT.txt +21 -0
  42. package/packages/visualizer/src/components/spec-logos/FILE-ICONS-ISC.txt +13 -0
  43. package/packages/visualizer/src/components/spec-logos/README.md +23 -0
  44. package/packages/visualizer/src/components/spec-logos/SIMPLE-ICONS-CC0.txt +30 -0
  45. package/packages/visualizer/src/components/spec-logos/alloy.svg +1 -0
  46. package/packages/visualizer/src/components/spec-logos/dmn.svg +3 -0
  47. package/packages/visualizer/src/components/spec-logos/gherkin.svg +1 -0
  48. package/packages/visualizer/src/components/spec-logos/openslo.svg +63 -0
  49. package/packages/visualizer/src/components/spec-logos/quint.svg +1 -0
  50. package/packages/visualizer/src/evidence/artifacts.tsx +263 -110
  51. package/packages/visualizer/src/evidence/chart-source.tsx +18 -0
  52. package/packages/visualizer/src/evidence/check-detail.tsx +283 -139
  53. package/packages/visualizer/src/evidence/decision-table.tsx +35 -26
  54. package/packages/visualizer/src/evidence/document-page.tsx +51 -29
  55. package/packages/visualizer/src/evidence/feature-page.tsx +121 -53
  56. package/packages/visualizer/src/evidence/marks.tsx +112 -79
  57. package/packages/visualizer/src/evidence/model-page.tsx +77 -46
  58. package/packages/visualizer/src/evidence/model-trace.tsx +55 -35
  59. package/packages/visualizer/src/evidence/overview.tsx +137 -69
  60. package/packages/visualizer/src/evidence/recording-page.tsx +70 -31
  61. package/packages/visualizer/src/evidence/recording.tsx +135 -68
  62. package/packages/visualizer/src/evidence/snapshot.tsx +99 -43
  63. package/packages/visualizer/src/evidence/source-text.tsx +33 -0
  64. package/packages/visualizer/src/evidence/sources-panel.tsx +138 -144
  65. package/packages/visualizer/src/evidence/spec-header.tsx +52 -36
  66. package/packages/visualizer/src/evidence/spec-index.tsx +61 -32
  67. package/packages/visualizer/src/evidence/spec-page.tsx +56 -25
  68. package/packages/visualizer/src/evidence/test-filter.tsx +38 -14
  69. package/packages/visualizer/src/explorer/Explorer.tsx +688 -261
  70. package/packages/visualizer/src/explorer/ExplorerIndex.tsx +136 -68
  71. package/packages/visualizer/src/explorer/RunsView.tsx +307 -94
  72. package/packages/visualizer/src/explorer/TestDetail.tsx +393 -118
  73. package/packages/visualizer/src/explorer/parts.tsx +35 -13
  74. package/packages/visualizer/src/find/Finder.tsx +106 -90
  75. package/packages/visualizer/src/find/open.ts +17 -20
  76. package/packages/visualizer/src/live/test-progress.tsx +60 -22
  77. package/packages/visualizer/src/looks/Stage.tsx +223 -110
  78. package/packages/visualizer/src/main.tsx +9 -11
  79. package/packages/visualizer/src/map/ChartMap.tsx +125 -69
  80. package/packages/visualizer/src/map/EventEdge.tsx +59 -37
  81. package/packages/visualizer/src/map/map.module.css +12 -546
  82. package/packages/visualizer/src/map/nodes.tsx +124 -70
  83. package/packages/visualizer/src/reachable.test.ts +3 -2
  84. package/packages/visualizer/src/spec-kind.ts +2 -2
  85. package/packages/visualizer/src/ui/grammars/ALLOY-MIT.txt +21 -0
  86. package/packages/visualizer/src/ui/grammars/QUINT-APACHE-2.0.txt +206 -0
  87. package/packages/visualizer/src/ui/grammars/README.md +14 -0
  88. package/packages/visualizer/src/ui/grammars/alloy.json +71 -0
  89. package/packages/visualizer/src/ui/grammars/quint.json +152 -0
  90. package/packages/visualizer/src/ui/highlight.ts +50 -0
  91. package/packages/visualizer/src/ui/theme.tsx +109 -0
  92. package/packages/visualizer/src/views/ChartPage.tsx +46 -26
  93. package/packages/visualizer/src/views/ChartTests.tsx +59 -22
  94. package/packages/visualizer/src/views/ProductPage.tsx +16 -15
  95. package/packages/visualizer/src/views/SidePanel.tsx +241 -131
  96. package/packages/visualizer/src/app/gazetteer.module.css +0 -107
  97. package/packages/visualizer/src/components/ActivityIndicator.module.css +0 -4
  98. package/packages/visualizer/src/components/DataIssues.module.css +0 -136
  99. package/packages/visualizer/src/components/Header.module.css +0 -114
  100. package/packages/visualizer/src/components/Legend.module.css +0 -84
  101. package/packages/visualizer/src/components/Screen.module.css +0 -312
  102. package/packages/visualizer/src/components/Status.module.css +0 -116
  103. package/packages/visualizer/src/components/icons.tsx +0 -156
  104. package/packages/visualizer/src/evidence/artifacts.module.css +0 -213
  105. package/packages/visualizer/src/evidence/evidence.module.css +0 -907
  106. package/packages/visualizer/src/evidence/recording.module.css +0 -460
  107. package/packages/visualizer/src/evidence/sources.module.css +0 -168
  108. package/packages/visualizer/src/evidence/spec-snapshot.tsx +0 -26
  109. package/packages/visualizer/src/explorer/explorer.module.css +0 -1238
  110. package/packages/visualizer/src/find/Finder.module.css +0 -168
  111. package/packages/visualizer/src/looks/stage.module.css +0 -300
  112. package/packages/visualizer/src/styles/global.css +0 -105
  113. package/packages/visualizer/src/styles/tokens.css +0 -80
  114. package/packages/visualizer/src/views/views.module.css +0 -546
@@ -1,3 +1,5 @@
1
+ import { Badge, Flex } from '@mantine/core';
2
+ import { statusColor } from '../ui/theme';
1
3
  /**
2
4
  * @module Explorer parts
3
5
  *
@@ -6,18 +8,19 @@
6
8
  */
7
9
  import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
8
10
  import type { TestSummary } from '@crvouga/atlas/reports/test-catalog';
9
-
10
11
  import { plural } from '../evidence/format';
11
12
  import { StatusMark } from '../evidence/marks';
12
13
  import { currentProject } from '../runtime';
13
- import styles from './explorer.module.css';
14
14
  import { environmentStatuses } from './groups';
15
-
16
15
  /** The project's name for its title: `?project=geviti-app` reads "Geviti App". */
17
16
  export function projectTitle(): string {
18
- return currentProject()?.split('-').map((word) => (word[0]?.toUpperCase() ?? '') + word.slice(1)).join(' ') || 'Project reports';
17
+ return (
18
+ currentProject()
19
+ ?.split('-')
20
+ .map((word) => (word[0]?.toUpperCase() ?? '') + word.slice(1))
21
+ .join(' ') || 'Project reports'
22
+ );
19
23
  }
20
-
21
24
  /** A run key for a person: its CI run number, or `branch@sha` shortened. */
22
25
  export function runName(key: string): string {
23
26
  const actions = /\/actions\/runs\/(\d+)/.exec(key);
@@ -26,7 +29,6 @@ export function runName(key: string): string {
26
29
  const [branch, sha] = key.split('@');
27
30
  return [branch, sha?.slice(0, 7)].filter(Boolean).join(' @ ') || key;
28
31
  }
29
-
30
32
  /** Small badges for what a test's results carry: flaky, video, screenshots, a trace, steps. */
31
33
  export function Badges({ test }: { test: TestSummary }) {
32
34
  const roles = test.evidence.roles;
@@ -37,23 +39,43 @@ export function Badges({ test }: { test: TestSummary }) {
37
39
  roles.includes('trace') ? { id: 'trace', label: 'Trace', title: 'Has a trace' } : null,
38
40
  test.evidence.steps > 0 ? { id: 'steps', label: plural(test.evidence.steps, 'step', 'steps'), title: 'Recorded steps' } : null,
39
41
  test.live ? { id: 'live', label: 'Running now', title: 'A run is in progress' } : null
40
- ].filter((badge): badge is { id: string; label: string; title: string } => badge !== null);
42
+ ].filter(
43
+ (
44
+ badge
45
+ ): badge is {
46
+ id: string;
47
+ label: string;
48
+ title: string;
49
+ } => badge !== null
50
+ );
41
51
  if (!badges.length) return null;
42
- return <span className={styles.badges}>{badges.map((badge) => <span key={badge.id} data-badge={badge.id} title={badge.title}>{badge.label}</span>)}</span>;
52
+ return (
53
+ <Flex component="span" display="inline-flex" gap="xs" align="center" wrap="wrap">
54
+ {badges.map((badge) => (
55
+ <Badge key={badge.id} color={badge.id === 'flaky' ? 'orange' : 'gray'} data-badge={badge.id} title={badge.title}>
56
+ {badge.label}
57
+ </Badge>
58
+ ))}
59
+ </Flex>
60
+ );
43
61
  }
44
-
45
62
  /** Where a test ran and how it went there: one chip per environment. */
46
63
  export function EnvironmentChips({ test }: { test: TestSummary }) {
47
64
  const environments = environmentStatuses(test);
48
65
  if (!environments.length) return null;
49
66
  return (
50
- <span className={styles.envChips}>
67
+ <Flex component="span" display="inline-flex" gap="xs" align="center" wrap="wrap">
51
68
  {environments.map(({ environment, status }) => (
52
- <span key={environment ?? 'none'} data-status={status} title={`${environmentLabel(environment)}: ${status}`}>
69
+ <Badge
70
+ key={environment ?? 'none'}
71
+ color={statusColor(status)}
72
+ data-status={status}
73
+ title={`${environmentLabel(environment)}: ${status}`}
74
+ >
53
75
  <StatusMark status={status} size={10} />
54
76
  {environmentLabel(environment)}
55
- </span>
77
+ </Badge>
56
78
  ))}
57
- </span>
79
+ </Flex>
58
80
  );
59
81
  }
@@ -1,18 +1,17 @@
1
- import { Command } from 'cmdk';
1
+ import { Badge, Group, Kbd, List, Modal, Text } from '@mantine/core';
2
+ import { Spotlight, useSpotlight } from '@mantine/spotlight';
2
3
  import { useQueryClient } from '@tanstack/react-query';
3
4
  import { useNavigate, useSearch } from '@tanstack/react-router';
4
5
  import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
5
-
6
6
  import { useAtlas } from '../app/atlas-context';
7
7
  import { keys, specKey, useAdapter } from '../data/queries';
8
- import { StatusMark } from '../evidence/marks';
9
8
  import { useEvidence } from '../evidence/evidence-context';
9
+ import { StatusMark } from '../evidence/marks';
10
10
  import { kindOfFile } from '../spec-kind';
11
11
  import { corpusFrom } from './corpus';
12
- import { commandKey, setFinderOpen, setHelpOpen, toggleFinder, useFinderOpen, useHelpOpen, isHelpOpen } from './open';
13
- import { FIND_GROUPS, indexCorpus, notationOf, rankIndex, splitQuery, type FindDoc, type FindKind, type FindTarget, type Ranked } from './rank';
12
+ import { finderStore, isHelpOpen, setFinderOpen, setHelpOpen, toggleFinder, useFinderOpen, useHelpOpen } from './open';
13
+ import { FIND_GROUPS, indexCorpus, notationOf, rankIndex, splitQuery, type FindDoc, type FindKind, type FindTarget } from './rank';
14
14
  import { applySemantic, createWorkerEmbedder, embeddingsAvailable, embedText, textHash, type Embedder } from './semantic';
15
- import styles from './Finder.module.css';
16
15
 
17
16
  const KIND_WORD: Record<FindKind, string> = {
18
17
  journey: 'Journey',
@@ -23,8 +22,10 @@ const KIND_WORD: Record<FindKind, string> = {
23
22
  action: 'Action',
24
23
  run: 'Run'
25
24
  };
26
-
27
- const SHORTCUTS: readonly { readonly keys: string; readonly does: string }[] = [
25
+ const SHORTCUTS: readonly {
26
+ readonly keys: string;
27
+ readonly does: string;
28
+ }[] = [
28
29
  { keys: '⌘K or Ctrl+K', does: 'Find a flow' },
29
30
  { keys: '/', does: 'Find a flow' },
30
31
  { keys: '↑ ↓', does: 'Move through the list' },
@@ -35,13 +36,11 @@ const SHORTCUTS: readonly { readonly keys: string; readonly does: string }[] = [
35
36
  { keys: '← →', does: 'Previous and next moment' },
36
37
  { keys: 'Home End', does: 'Start and end of a recording' }
37
38
  ];
38
-
39
39
  function isTypingTarget(target: EventTarget | null) {
40
40
  if (!(target instanceof HTMLElement)) return false;
41
41
  const tag = target.tagName;
42
42
  return target.isContentEditable || tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'BUTTON' || tag === 'A';
43
43
  }
44
-
45
44
  function useSpecTexts(files: readonly string[]) {
46
45
  const adapter = useAdapter();
47
46
  const client = useQueryClient();
@@ -84,10 +83,8 @@ function useSpecTexts(files: readonly string[]) {
84
83
  }, [adapter, client, key, source]);
85
84
  return { texts, pending };
86
85
  }
87
-
88
86
  /** The vector cache outlives a keystroke, so a live run does not embed the suite again. */
89
87
  const vectorCache = new Map<string, Float32Array>();
90
-
91
88
  function vectorsFor(docs: readonly FindDoc[]): ReadonlyMap<string, Float32Array> {
92
89
  const vectors = new Map<string, Float32Array>();
93
90
  for (const doc of docs) {
@@ -96,7 +93,6 @@ function vectorsFor(docs: readonly FindDoc[]): ReadonlyMap<string, Float32Array>
96
93
  }
97
94
  return vectors;
98
95
  }
99
-
100
96
  export function Finder() {
101
97
  const open = useFinderOpen();
102
98
  const help = useHelpOpen();
@@ -106,12 +102,20 @@ export function Finder() {
106
102
  const routeSearch = useSearch({ strict: false });
107
103
  const readable = useMemo(() => evidence.files.filter((file) => kindOfFile(file)), [evidence.files]);
108
104
  const { texts, pending } = useSpecTexts(readable);
109
- const docs = useMemo(() => corpusFrom(view, evidence.files, evidence.titleOf, evidence.view, texts), [view, evidence.files, evidence.titleOf, evidence.view, texts]);
105
+ const docs = useMemo(
106
+ () => corpusFrom(view, evidence.files, evidence.titleOf, evidence.view, texts),
107
+ [view, evidence.files, evidence.titleOf, evidence.view, texts]
108
+ );
110
109
  const index = useMemo(() => indexCorpus(docs), [docs]);
111
110
  const [query, setQuery] = useState('');
112
- const [picked, setPicked] = useState('');
111
+ const store = finderStore;
112
+ const spotlightState = useSpotlight(store);
113
+
113
114
  const lexical = useMemo(() => rankIndex(index, query), [index, query]);
114
- const [embedded, setEmbedded] = useState<{ readonly query: string; readonly vector: Float32Array } | null>(null);
115
+ const [embedded, setEmbedded] = useState<{
116
+ readonly query: string;
117
+ readonly vector: Float32Array;
118
+ } | null>(null);
115
119
  const [vectors, setVectors] = useState<ReadonlyMap<string, Float32Array>>(() => new Map());
116
120
  const queryVector = embedded !== null && embedded.query === query.trim() ? embedded.vector : null;
117
121
  const searchable = useMemo(() => {
@@ -119,18 +123,23 @@ export function Finder() {
119
123
  if (notations.size === 0) return docs;
120
124
  return docs.filter((doc) => notations.has(notationOf(doc)));
121
125
  }, [docs, query]);
122
- const shown = queryVector ? applySemantic(searchable, lexical, queryVector, vectors) : lexical;
126
+ const shown = useMemo(
127
+ () => (queryVector ? applySemantic(searchable, lexical, queryVector, vectors) : lexical),
128
+ [queryVector, searchable, lexical, vectors]
129
+ );
123
130
  const ordered = useMemo(() => FIND_GROUPS.flatMap((group) => shown.filter((hit) => hit.doc.group === group)), [shown]);
124
- const selected = ordered.some((hit) => hit.doc.id === picked) ? picked : (ordered[0]?.doc.id ?? '');
131
+
132
+ // Rank results ourselves, then let Spotlight own keyboard selection and scrolling.
133
+ // Compound Spotlight does not initialize a selection when asynchronous results arrive.
134
+ useEffect(() => {
135
+ if (open) store.setState((state) => ({ ...state, selected: 0 }));
136
+ }, [open, query, store]);
125
137
  const embedder = useRef<Embedder | null>(null);
126
138
  const embedFailed = useRef(false);
127
-
128
139
  useEffect(() => {
129
140
  if (open) return;
130
141
  setQuery('');
131
- setPicked('');
132
142
  }, [open]);
133
-
134
143
  useEffect(() => {
135
144
  const onKey = (event: KeyboardEvent) => {
136
145
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k' && !event.altKey && !event.shiftKey) {
@@ -157,7 +166,6 @@ export function Finder() {
157
166
  window.addEventListener('keydown', onKey);
158
167
  return () => window.removeEventListener('keydown', onKey);
159
168
  }, []);
160
-
161
169
  useEffect(() => {
162
170
  if (!open || embedFailed.current || !embeddingsAvailable() || query.trim().length === 0) return;
163
171
  let cancelled = false;
@@ -192,7 +200,6 @@ export function Finder() {
192
200
  cancelled = true;
193
201
  };
194
202
  }, [open, query, docs, lexical]);
195
-
196
203
  const openTarget = (target: FindTarget) => {
197
204
  const run = typeof routeSearch.run === 'string' ? routeSearch.run : undefined;
198
205
  if (target.to === '/chart/$chartId' && target.chartId) {
@@ -214,86 +221,95 @@ export function Finder() {
214
221
  }
215
222
  setFinderOpen(false);
216
223
  };
217
-
218
224
  const onCommandKey = (event: ReactKeyboardEvent) => {
219
225
  if (event.key !== 'Enter' || (!event.metaKey && !event.ctrlKey)) return;
220
226
  event.preventDefault();
221
- const hit = shown.find((item) => item.doc.id === selected);
227
+ event.stopPropagation();
228
+ const hit = ordered[Math.max(0, store.getState().selected)];
222
229
  if (hit) openTarget(hit.doc.recording ?? hit.doc.target);
223
230
  };
224
-
225
231
  return (
226
232
  <>
227
- <Command.Dialog
228
- open={open}
229
- onOpenChange={setFinderOpen}
230
- label="Find"
231
- shouldFilter={false}
232
- loop
233
- vimBindings={false}
234
- overlayClassName={styles.overlay}
235
- contentClassName={styles.content}
236
- className={styles.command}
237
- value={selected}
238
- onValueChange={setPicked}
239
- onKeyDown={onCommandKey}
233
+ <Spotlight.Root
234
+ store={store}
235
+ shortcut={null}
236
+ query={query}
237
+ onQueryChange={setQuery}
238
+ title="Find"
239
+ attributes={{ content: { 'aria-label': 'Find' } }}
240
+ onKeyDownCapture={onCommandKey}
241
+ scrollable
242
+ maxHeight="60vh"
240
243
  >
241
- <Command.Input className={styles.input} placeholder='Try "add a todo" or "gherkin"' value={query} onValueChange={(value) => { setQuery(value); setPicked(''); }} />
242
- <Command.List className={styles.list} label="Flows, specs and runs">
243
- {shown.length === 0 && <p className={styles.empty}>{query.trim() ? 'Nothing close to that.' : 'Type what someone does. For example, add a todo.'}</p>}
244
+ {/* Spotlight updates selection silently; notify its public store after keyboard handling so ARIA follows it. */}
245
+ <Spotlight.Search
246
+ onKeyDown={() => queueMicrotask(() => store.setState(store.getState()))}
247
+ role="combobox"
248
+ aria-expanded={open}
249
+ aria-controls="atlas-finder-actions"
250
+ aria-activedescendant={ordered[Math.max(0, spotlightState.selected)]?.doc.id}
251
+ aria-label="Find a flow"
252
+ placeholder='Try "add a todo" or "gherkin"'
253
+ />
254
+ <Spotlight.ActionsList id="atlas-finder-actions" role="listbox" aria-label="Flows, specs and runs">
255
+ {shown.length === 0 && (
256
+ <Spotlight.Empty>
257
+ {query.trim() ? 'Nothing close to that.' : 'Type what someone does. For example, add a todo.'}
258
+ </Spotlight.Empty>
259
+ )}
244
260
  {FIND_GROUPS.map((group) => {
245
261
  const hits = shown.filter((hit) => hit.doc.group === group);
246
- if (hits.length === 0) return null;
247
- return (
248
- <Command.Group key={group} heading={group} value={group}>
262
+ return hits.length ? (
263
+ <Spotlight.ActionsGroup key={group} label={group}>
249
264
  {hits.map((hit) => (
250
- <Command.Item key={hit.doc.id} className={styles.item} value={hit.doc.id} onSelect={() => openTarget(hit.doc.target)}>
251
- <Row hit={hit} />
252
- </Command.Item>
265
+ <Spotlight.Action
266
+ key={hit.doc.id}
267
+ id={hit.doc.id}
268
+ ref={(node) => {
269
+ if (node) node.id = hit.doc.id;
270
+ }}
271
+ role="option"
272
+ data-selected={ordered[Math.max(0, spotlightState.selected)]?.doc.id === hit.doc.id || undefined}
273
+ aria-selected={ordered[Math.max(0, spotlightState.selected)]?.doc.id === hit.doc.id}
274
+ onClick={() => openTarget(hit.doc.target)}
275
+ label={hit.doc.title}
276
+ description={[hit.snippet !== hit.doc.subtitle ? hit.snippet : '', hit.doc.subtitle].filter(Boolean).join(' · ')}
277
+ leftSection={hit.doc.status ? <StatusMark status={hit.doc.status} size={16} /> : undefined}
278
+ rightSection={<Badge color="gray">{KIND_WORD[hit.doc.kind]}</Badge>}
279
+ />
253
280
  ))}
254
- </Command.Group>
255
- );
281
+ </Spotlight.ActionsGroup>
282
+ ) : null;
256
283
  })}
257
- </Command.List>
258
- <footer className={styles.footer}>
259
- <span>{pending ? 'Reading specs…' : '↑↓ move'}</span>
260
- <span>↵ open</span>
261
- <span>esc close</span>
262
- </footer>
263
- </Command.Dialog>
264
- {help && (
265
- <div className={styles.overlay} onClick={() => setHelpOpen(false)}>
266
- <div className={styles.help} role="dialog" aria-label="Keyboard shortcuts" onClick={(event) => event.stopPropagation()}>
267
- <h2>Keyboard shortcuts</h2>
268
- <ul>
269
- {SHORTCUTS.map((shortcut) => (
270
- <li key={shortcut.keys}>
271
- <kbd>{shortcut.keys}</kbd>
272
- <span>{shortcut.does}</span>
273
- </li>
274
- ))}
275
- </ul>
276
- <button type="button" onClick={() => setHelpOpen(false)}>Close</button>
277
- </div>
278
- </div>
279
- )}
280
- </>
281
- );
282
- }
283
-
284
- function Row({ hit }: { hit: Ranked }) {
285
- const { doc, snippet } = hit;
286
- return (
287
- <>
288
- {doc.status && <StatusMark status={doc.status} size={14} />}
289
- <span className={styles.text}>
290
- <span className={styles.title}>{doc.title}</span>
291
- {snippet && snippet !== doc.subtitle && <span className={styles.snippet}>{snippet}</span>}
292
- </span>
293
- <span className={styles.meta}>
294
- {doc.subtitle && <span className={styles.subtitle}>{doc.subtitle}</span>}
295
- <span className={styles.kind}>{KIND_WORD[doc.kind]}</span>
296
- </span>
284
+ </Spotlight.ActionsList>
285
+ <Spotlight.Footer>
286
+ <Group gap="md" justify="space-between">
287
+ <Text size="xs" c="dimmed">
288
+ {pending ? 'Reading specs…' : '↑↓ move'}
289
+ </Text>
290
+ <Text size="xs" c="dimmed">
291
+ ↵ open · esc close
292
+ </Text>
293
+ </Group>
294
+ </Spotlight.Footer>
295
+ </Spotlight.Root>
296
+ <Modal
297
+ opened={help}
298
+ onClose={() => setHelpOpen(false)}
299
+ title="Keyboard shortcuts"
300
+ attributes={{ content: { 'aria-label': 'Keyboard shortcuts' } }}
301
+ >
302
+ <List spacing="md" listStyleType="none">
303
+ {SHORTCUTS.map((shortcut) => (
304
+ <List.Item key={shortcut.keys}>
305
+ <Group justify="space-between">
306
+ <Kbd>{shortcut.keys}</Kbd>
307
+ <Text>{shortcut.does}</Text>
308
+ </Group>
309
+ </List.Item>
310
+ ))}
311
+ </List>
312
+ </Modal>
297
313
  </>
298
314
  );
299
315
  }
@@ -1,55 +1,52 @@
1
+ import { createSpotlight } from '@mantine/spotlight';
1
2
  import { useSyncExternalStore } from 'react';
2
3
 
3
- let open = false;
4
+ /** The application's finder and Mantine Spotlight share one store, including under React StrictMode. */
5
+ export const [finderStore, finderControls] = createSpotlight();
4
6
  let help = false;
5
7
  const listeners = new Set<() => void>();
6
-
7
8
  function emit() {
8
9
  for (const listener of listeners) listener();
9
10
  }
10
-
11
11
  export function subscribeFinder(listener: () => void) {
12
12
  listeners.add(listener);
13
- return () => listeners.delete(listener);
13
+ const stop = finderStore.subscribe(listener);
14
+ return () => {
15
+ listeners.delete(listener);
16
+ stop();
17
+ };
14
18
  }
15
-
16
19
  export function isFinderOpen() {
17
- return open;
20
+ return finderStore.getState().opened;
18
21
  }
19
-
20
22
  export function isHelpOpen() {
21
23
  return help;
22
24
  }
23
-
24
- /** Recording keys stay put while either layer is up. */
25
+ /** Playback shortcuts are suspended whenever a finder or help overlay is open. */
25
26
  export function finderBlocksKeys() {
26
- return open || help;
27
+ return isFinderOpen() || help;
27
28
  }
28
-
29
29
  export function setFinderOpen(value: boolean) {
30
- open = value;
31
- if (value) help = false;
30
+ if (value) {
31
+ help = false;
32
+ finderControls.open();
33
+ } else finderControls.close();
32
34
  emit();
33
35
  }
34
-
35
36
  export function toggleFinder() {
36
- setFinderOpen(!open);
37
+ setFinderOpen(!isFinderOpen());
37
38
  }
38
-
39
39
  export function setHelpOpen(value: boolean) {
40
40
  help = value;
41
- if (value) open = false;
41
+ if (value) finderControls.close();
42
42
  emit();
43
43
  }
44
-
45
44
  export function useFinderOpen() {
46
45
  return useSyncExternalStore(subscribeFinder, isFinderOpen, () => false);
47
46
  }
48
-
49
47
  export function useHelpOpen() {
50
48
  return useSyncExternalStore(subscribeFinder, isHelpOpen, () => false);
51
49
  }
52
-
53
50
  export function commandKey() {
54
51
  return typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘K' : 'Ctrl+K';
55
52
  }
@@ -1,34 +1,72 @@
1
1
  import type { CheckSlot } from '@crvouga/atlas/reports/evidence-view';
2
+ import { Alert, Code, Flex, List, Stack, Text } from '@mantine/core';
2
3
  import { StatusMark } from '../evidence/marks';
3
- import styles from '../evidence/evidence.module.css';
4
4
  import { useLiveTests, type LiveTest } from './live-runs';
5
5
  import { testsForSlot } from './report-progress';
6
-
7
6
  export function useTestProgress(slot: CheckSlot | null) {
8
7
  const { index, error } = useLiveTests();
9
8
  return { tests: slot ? testsForSlot(index, slot) : [], error };
10
9
  }
11
-
12
10
  /** A test's own progress log. Historical evidence stays available below it. */
13
11
  export function TestProgress({ tests, error }: { tests: readonly LiveTest[]; error?: string | null }) {
14
12
  if (!tests.length) return null;
15
- return <div aria-label="Test progress">
16
- {tests.map(({ run, check }) => {
17
- const running = run.status === 'running' && check.status === 'running';
18
- const interrupted = run.status === 'interrupted' && check.status === 'not-run';
19
- const status = running ? 'running' : interrupted ? 'not-run' : check.status === 'running' ? 'not-run' : check.status;
20
- return <div key={`${run.id}:${check.id}`}>
21
- <p className={styles.detailMeta} role="status"><StatusMark status={status} />
22
- <strong>{running ? 'Running' : interrupted ? 'Interrupted' : status === 'passed' ? 'Passed' : status === 'failed' ? 'Failed' : 'Not run'}</strong>
23
- <span>{run.producer}{run.environment ? ` · ${run.environment}` : ''}</span>
24
- </p>
25
- {check.detail && check.detail !== run.reason && <pre className={styles.pre}>{check.detail}</pre>}
26
- {!!check.steps?.length && <ol className={styles.steps} aria-label="Progress log">{check.steps.map((step, index) => <li key={index}>
27
- <div className={styles.stepRow}><StatusMark status={step.status === 'passed' || step.status === 'failed' || step.status === 'running' ? step.status : 'none'} size={14} /><span className={styles.stepText}>{step.name}</span></div>
28
- </li>)}</ol>}
29
- {interrupted && <p className={styles.notice} role="status">{run.reason ?? 'The test run was interrupted.'} No completed outcome was received for this test.</p>}
30
- </div>;
31
- })}
32
- {error && <p className={styles.notice} role="alert">{error}</p>}
33
- </div>;
13
+ return (
14
+ <Stack aria-label="Test progress" gap="sm">
15
+ {tests.map(({ run, check }) => {
16
+ const running = run.status === 'running' && check.status === 'running';
17
+ const interrupted = run.status === 'interrupted' && check.status === 'not-run';
18
+ const status = running ? 'running' : interrupted ? 'not-run' : check.status === 'running' ? 'not-run' : check.status;
19
+ return (
20
+ <Stack key={`${run.id}:${check.id}`} gap="sm">
21
+ <Flex role="status" gap="xs" align="center" wrap="wrap" c="dimmed">
22
+ <StatusMark status={status} />
23
+ <Text span inherit fw={600}>
24
+ {running
25
+ ? 'Running'
26
+ : interrupted
27
+ ? 'Interrupted'
28
+ : status === 'passed'
29
+ ? 'Passed'
30
+ : status === 'failed'
31
+ ? 'Failed'
32
+ : 'Not run'}
33
+ </Text>
34
+ <Text span inherit>
35
+ {run.producer}
36
+ {run.environment ? ` · ${run.environment}` : ''}
37
+ </Text>
38
+ </Flex>
39
+ {check.detail && check.detail !== run.reason && <Code block>{check.detail}</Code>}
40
+ {!!check.steps?.length && (
41
+ <List aria-label="Progress log" spacing="xs" listStyleType="none" type="ordered">
42
+ {check.steps.map((step, index) => (
43
+ <List.Item key={index}>
44
+ <Flex gap="xs" align="center" wrap="wrap">
45
+ <StatusMark
46
+ status={step.status === 'passed' || step.status === 'failed' || step.status === 'running' ? step.status : 'none'}
47
+ size={14}
48
+ />
49
+ <Text span inherit>
50
+ {step.name}
51
+ </Text>
52
+ </Flex>
53
+ </List.Item>
54
+ ))}
55
+ </List>
56
+ )}
57
+ {interrupted && (
58
+ <Alert role="status" color={'blue'} my="sm">
59
+ {run.reason ?? 'The test run was interrupted.'} No completed outcome was received for this test.
60
+ </Alert>
61
+ )}
62
+ </Stack>
63
+ );
64
+ })}
65
+ {error && (
66
+ <Alert role="alert" color={'red'} my="sm">
67
+ {error}
68
+ </Alert>
69
+ )}
70
+ </Stack>
71
+ );
34
72
  }