@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,105 +1,112 @@
1
+ import { SpecLogo } from './SpecLogo';
2
+ import { IconDots } from '@tabler/icons-react';
3
+ import { isStatechartFile } from '@crvouga/atlas/schema';
4
+ import { ActionIcon, Anchor, Badge, Box, Button, Group, Menu, NativeSelect, Popover, Stack, Tooltip } from '@mantine/core';
1
5
  import { Link, useLocation, useNavigate, useSearch } from '@tanstack/react-router';
2
- import { useEffect, useMemo, useRef, type ReactNode } from 'react';
3
-
6
+ import { useEffect, useMemo, useRef, useState } from 'react';
4
7
  import { useAtlas } from '../app/atlas-context';
5
- import { setFinderOpen } from '../find/open';
6
8
  import type { AtlasView } from '../data/model';
7
9
  import { LATEST } from '../data/queries';
8
10
  import { useEvidence } from '../evidence/evidence-context';
9
11
  import { plural } from '../evidence/format';
10
12
  import { excludedCount } from '../evidence/sources-panel';
13
+ import { setFinderOpen } from '../find/open';
11
14
  import { useLiveStatus } from '../live/live-stream';
15
+ import { currentProject, runtimeConfig } from '../runtime';
12
16
  import { kindOfAddress, outlinePath, SPEC_KINDS } from '../spec-kind';
13
- import { runtimeConfig } from '../runtime';
14
17
  import { useUiStore } from '../state/ui-store';
15
- import { DataIssues } from './DataIssues';
16
18
  import { ActivityIndicator } from './ActivityIndicator';
17
- import styles from './Header.module.css';
18
- import { ProjectPicker, ProjectNotice } from './ProjectPicker';
19
- import { isStatechartFile } from '@crvouga/atlas/schema';
20
- import { SearchIcon, ChevronDownIcon } from './icons';
21
-
19
+ import { DataIssues } from './DataIssues';
20
+ import { IconChevronDown as ChevronDownIcon, IconSearch as SearchIcon } from '@tabler/icons-react';
21
+ import { ProjectNotice, ProjectPicker } from './ProjectPicker';
22
22
  const dateTime = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
23
-
24
23
  export function formatRunTime(iso: string) {
25
24
  const d = new Date(iso);
26
25
  return Number.isNaN(d.getTime()) ? 'Unknown time' : dateTime.format(d);
27
26
  }
28
-
29
- /** The product's own name, without the subtitle a spec title may carry: "Gamezilla — the whole product" is Gamezilla. */
27
+ /** The product's own name, without the subtitle a spec title may carry. */
30
28
  export function productName(title: string | undefined) {
31
- const name = title?.split(/\s+[—–|-]\s+|:\s+/)[0]?.trim();
32
- return name || 'Atlas';
29
+ return title?.split(/\s+[—–|-]\s+|:\s+/)[0]?.trim() || 'Atlas';
33
30
  }
34
-
35
31
  function RunPicker({ view }: { view: AtlasView }) {
36
32
  const search = useSearch({ strict: false });
37
33
  const navigate = useNavigate();
38
34
  const { latestRunId } = useAtlas();
39
- if (view.runs.length === 0) return null;
35
+ if (!view.runs.length) return null;
40
36
  const latest = view.runs.find((run) => run.id === latestRunId);
41
37
  return (
42
- <label className={styles.picker}>
43
- <span className="sr-only">Run</span>
44
- <select
45
- className={styles.select}
46
- value={search.run ?? LATEST}
47
- onChange={(event) => void navigate({ to: '.', search: (prev) => ({ ...prev, run: event.target.value === LATEST ? undefined : event.target.value }) })}
48
- >
49
- <option value={LATEST}>Latest{latest ? ` · ${formatRunTime(latest.startedAt)}` : ''}</option>
50
- {view.runs.map((run) => (
51
- <option key={run.id} value={run.id} disabled={run.progress === 'running'}>
52
- {formatRunTime(run.startedAt)} · {run.progress === 'running' ? 'still running' : run.mode === 'showcase' ? 'with videos' : 'quick check'}
53
- </option>
54
- ))}
55
- </select>
56
- </label>
38
+ <NativeSelect
39
+ label="Run"
40
+ value={search.run ?? LATEST}
41
+ onChange={(event) =>
42
+ void navigate({ to: '.', search: (prev) => ({ ...prev, run: event.target.value === LATEST ? undefined : event.target.value }) })
43
+ }
44
+ >
45
+ <option value={LATEST}>Latest{latest ? ` · ${formatRunTime(latest.startedAt)}` : ''}</option>
46
+ {view.runs.map((run) => (
47
+ <option key={run.id} value={run.id} disabled={run.progress === 'running'}>
48
+ {formatRunTime(run.startedAt)} ·{' '}
49
+ {run.progress === 'running' ? 'still running' : run.mode === 'showcase' ? 'with videos' : 'quick check'}
50
+ </option>
51
+ ))}
52
+ </NativeSelect>
57
53
  );
58
54
  }
59
-
60
55
  function runOnly(prev: { run?: unknown }) {
61
56
  return typeof prev.run === 'string' ? { run: prev.run } : {};
62
57
  }
63
-
64
- /** Spec runs writing results right now. Their results are already in the lists below. */
65
58
  function LiveRuns() {
66
59
  const { active } = useEvidence();
67
60
  if (!active.length) return null;
68
- const results = active.reduce((sum, run) => sum + run.completed, 0);
69
61
  return (
70
- <span className={styles.live} role="status" title={active.map((run) => run.packageDir).join(', ')}>
71
- <span className={styles.liveDot} aria-hidden="true" />
72
- Running {active.length === 1 ? active[0]!.packageDir.split('/').at(-1) : plural(active.length, 'suite', 'suites')} · {plural(results, 'result', 'results')}
73
- </span>
62
+ <Badge color="blue" variant="light" role="status">
63
+ Running {active.length === 1 ? active[0]!.packageDir.split('/').at(-1) : plural(active.length, 'suite', 'suites')} ·{' '}
64
+ {plural(
65
+ active.reduce((n, run) => n + run.completed, 0),
66
+ 'result',
67
+ 'results'
68
+ )}
69
+ </Badge>
74
70
  );
75
71
  }
76
-
77
- /** A fixed-size connection indicator; incoming results never change the header's width. */
78
72
  function ServerConnection() {
79
73
  const status = useLiveStatus();
80
74
  if (status === 'off') return null;
81
- const label = status === 'live' ? 'Live updates connected' : status === 'connecting' ? 'Connecting to live updates' : 'Live updates disconnected; reconnecting';
75
+ const label =
76
+ status === 'live'
77
+ ? 'Live updates connected'
78
+ : status === 'connecting'
79
+ ? 'Connecting to live updates'
80
+ : 'Live updates disconnected; reconnecting';
82
81
  return (
83
- <span className={styles.connection} data-status={status} role="status" aria-label={label} title={label}>
84
- <span className={styles.connectionDot} aria-hidden="true" />
85
- <span className="sr-only">{label}</span>
86
- </span>
82
+ <Tooltip label={label}>
83
+ <Badge size="sm" color={status === 'live' ? 'green' : 'gray'} role="status" aria-label={label}>
84
+ {status === 'live' ? 'Live' : 'Connecting'}
85
+ </Badge>
86
+ </Tooltip>
87
87
  );
88
88
  }
89
-
90
- function SourcesButton() {
89
+ function SourcesButton({ onOpen }: { onOpen: () => void }) {
91
90
  const { layers, ready, unavailable, excluded } = useEvidence();
92
91
  const setOpen = useUiStore((s) => s.setSourcesOpen);
93
92
  if (!ready && !unavailable) return null;
94
93
  const off = excludedCount(excluded);
95
94
  return (
96
- <button type="button" className={styles.sources} data-filtered={off > 0 || undefined} onClick={() => setOpen(true)}>
95
+ <Button
96
+ onClick={() => {
97
+ onOpen();
98
+ setOpen(true);
99
+ }}
100
+ rightSection={
101
+ <Badge color={off ? 'orange' : 'gray'}>
102
+ {unavailable ? 'unavailable' : off ? `${off} off` : plural(layers.length, 'layer', 'layers')}
103
+ </Badge>
104
+ }
105
+ >
97
106
  Sources
98
- <span>{unavailable ? 'unavailable' : off > 0 ? `${off} off` : plural(layers.length, 'layer', 'layers')}</span>
99
- </button>
107
+ </Button>
100
108
  );
101
109
  }
102
-
103
110
  export function Header() {
104
111
  const { view } = useAtlas();
105
112
  const evidence = useEvidence();
@@ -107,6 +114,12 @@ export function Header() {
107
114
  const kind = kindOfAddress(pathname);
108
115
  const product = productName(view?.title);
109
116
  const hasCharts = evidence.files.some(isStatechartFile);
117
+ const sourcesOpen = useUiStore((s) => s.sourcesOpen);
118
+ const [optionsOpen, setOptionsOpen] = useState(false);
119
+ const optionsTarget = useRef<HTMLButtonElement>(null);
120
+ useEffect(() => {
121
+ if (sourcesOpen) setOptionsOpen(false);
122
+ }, [sourcesOpen]);
110
123
  useEffect(() => {
111
124
  document.title = product;
112
125
  }, [product]);
@@ -114,73 +127,110 @@ export function Header() {
114
127
  () =>
115
128
  SPEC_KINDS.map((entry) => {
116
129
  const files = evidence.files.filter(entry.accepts);
117
- return { kind: entry, files: files.length, running: files.some(file => (evidence.view.specs.get(outlinePath(file))?.summary.counts.running ?? 0) > 0), failing: files.filter((file) => (evidence.view.specs.get(outlinePath(file))?.summary.counts.failed ?? 0) > 0).length };
130
+ return {
131
+ kind: entry,
132
+ files: files.length,
133
+ running: files.some((file) => (evidence.view.specs.get(outlinePath(file))?.summary.counts.running ?? 0) > 0),
134
+ failing: files.filter((file) => (evidence.view.specs.get(outlinePath(file))?.summary.counts.failed ?? 0) > 0).length
135
+ };
118
136
  }).filter((tab) => tab.files > 0 || tab.kind === kind),
119
137
  [evidence.files, evidence.view, kind]
120
138
  );
139
+ const project = currentProject();
121
140
  return (
122
- <header className={styles.header}>
123
- <Link to="/" search={(prev) => runOnly(prev)} className={styles.home}>
124
- Atlas
125
- </Link>
126
- <div className={styles.breadcrumbs}>
127
- <HeaderMenu label="Spec type" working={tabs.some(tab => tab.running)} caption={kind?.label ?? (hasCharts ? 'Statecharts' : evidence.ready && !tabs.length ? 'Tests' : 'Specs')}>
128
- <nav className={styles.tabs} aria-label="Spec type" onClick={event => event.currentTarget.closest('details')?.removeAttribute('open')}>
129
- <Link to="/" search={(prev) => runOnly(prev)} aria-current={pathname.startsWith('/specs/') ? undefined : 'page'}>
130
- {hasCharts || !evidence.ready ? 'Statecharts' : 'Tests'}
131
- </Link>
132
- {tabs.map((tab) => (
133
- <Link key={tab.kind.id} to="/specs/$kind" params={{ kind: tab.kind.id }} search={{}} aria-current={tab.kind === kind ? 'page' : undefined}>
134
- {tab.kind.label}
135
- {tab.running && <ActivityIndicator label={`${tab.kind.label} tests running`} />}
136
- {tab.failing > 0 && <span className={styles.failing} aria-label={`${plural(tab.failing, tab.kind.one, tab.kind.many)} failing`}>{tab.failing}</span>}
137
- </Link>
138
- ))}
139
- </nav>
140
- </HeaderMenu>
141
- <SpecBreadcrumb />
142
- </div>
143
- <div className={styles.tools}>
144
- <ServerConnection />
145
- <button type="button" className={styles.search} aria-label="Find a flow" title="Search (⌘K / Ctrl+K)" aria-keyshortcuts="Meta+K Control+K" onClick={() => setFinderOpen(true)}>
146
- <SearchIcon size={16} />
147
- </button>
148
- <HeaderMenu label="View options" caption="•••" tools>
149
- <div className={styles.options}>
150
- {!kind && view && <div className={styles.optionGroup}><span className={styles.optionLabel}>Run</span><RunPicker view={view} /></div>}
151
- <LiveRuns />
152
- <SourcesButton />
153
- {!kind && view && <DataIssues issues={view.issues} />}
154
- {runtimeConfig()?.api && <a href="/admin" className={styles.sources}>Settings</a>}
155
- <ProjectNotice />
156
- </div>
157
- </HeaderMenu>
141
+ <Group flex={1} miw={0} justify="space-between" wrap="nowrap" gap="xs">
142
+ <Group gap="xs" wrap="nowrap" miw={0}>
143
+ <Anchor fw={800} size="lg" underline="never" renderRoot={(props) => <Link {...props} to="/" search={(prev) => runOnly(prev)} />}>
144
+ Atlas
145
+ </Anchor>
146
+ <Menu keepMounted position="bottom-start">
147
+ <Menu.Target>
148
+ <Button
149
+ variant="subtle"
150
+ aria-label="Spec type"
151
+ leftSection={kind ? <SpecLogo kind={kind} /> : undefined}
152
+ rightSection={<ChevronDownIcon aria-hidden="true" size={12} />}
153
+ >
154
+ {kind?.label ?? (hasCharts ? 'Statecharts' : evidence.ready && !tabs.length ? 'Tests' : 'Specs')}
155
+ </Button>
156
+ </Menu.Target>
157
+ <Menu.Dropdown>
158
+ <Box component="nav" aria-label="Spec type">
159
+ <Menu.Item
160
+ aria-current={pathname.startsWith('/specs/') ? undefined : 'page'}
161
+ renderRoot={(props) => <Link {...props} to="/" search={(prev) => runOnly(prev)} />}
162
+ >
163
+ {hasCharts || !evidence.ready ? 'Statecharts' : 'Tests'}
164
+ </Menu.Item>
165
+ {tabs.map((tab) => (
166
+ <Menu.Item
167
+ key={tab.kind.id}
168
+ leftSection={<SpecLogo kind={tab.kind} />}
169
+ aria-current={tab.kind === kind ? 'page' : undefined}
170
+ rightSection={
171
+ tab.failing ? (
172
+ <Badge color="red">{tab.failing}</Badge>
173
+ ) : tab.running ? (
174
+ <ActivityIndicator label={`${tab.kind.label} tests running`} />
175
+ ) : undefined
176
+ }
177
+ renderRoot={(props) => <Link {...props} to="/specs/$kind" params={{ kind: tab.kind.id }} search={{}} />}
178
+ >
179
+ {tab.kind.label}
180
+ </Menu.Item>
181
+ ))}
182
+ </Box>
183
+ </Menu.Dropdown>
184
+ </Menu>
185
+ <Box visibleFrom="sm" miw={0}>
186
+ <SpecBreadcrumb />
187
+ </Box>
188
+ </Group>
189
+ <Group gap="xs" wrap="nowrap">
190
+ <Box visibleFrom="sm">
191
+ <ServerConnection />
192
+ </Box>
193
+ <Tooltip label="Search (⌘K / Ctrl+K)">
194
+ <ActionIcon aria-label="Find a flow" aria-keyshortcuts="Meta+K Control+K" onClick={() => setFinderOpen(true)}>
195
+ <SearchIcon aria-hidden="true" size={18} />
196
+ </ActionIcon>
197
+ </Tooltip>
198
+ <Popover
199
+ opened={optionsOpen && !sourcesOpen}
200
+ onChange={setOptionsOpen}
201
+ disabled={sourcesOpen}
202
+ keepMounted
203
+ position="bottom-end"
204
+ width={320}
205
+ trapFocus
206
+ >
207
+ <Popover.Target>
208
+ <ActionIcon ref={optionsTarget} aria-label="View options" onClick={() => setOptionsOpen((open) => !open)}>
209
+ <IconDots size={20} aria-hidden="true" />
210
+ </ActionIcon>
211
+ </Popover.Target>
212
+ <Popover.Dropdown>
213
+ <Stack gap="sm">
214
+ {!kind && view && <RunPicker view={view} />}
215
+ <LiveRuns />
216
+ <SourcesButton
217
+ onOpen={() => {
218
+ setOptionsOpen(false);
219
+ // The drawer returns focus to this persistent trigger, not its disappearing Sources button.
220
+ optionsTarget.current?.focus();
221
+ }}
222
+ />
223
+ {!kind && view && <DataIssues issues={view.issues} />}
224
+ {runtimeConfig()?.api && <Anchor href={`/admin${project ? `?project=${encodeURIComponent(project)}` : ''}`}>Settings</Anchor>}
225
+ <ProjectNotice />
226
+ </Stack>
227
+ </Popover.Dropdown>
228
+ </Popover>
158
229
  <ProjectPicker />
159
- </div>
160
- </header>
230
+ </Group>
231
+ </Group>
161
232
  );
162
233
  }
163
-
164
- function HeaderMenu({ label, caption, tools = false, working = false, children }: { label: string; caption: string; tools?: boolean; working?: boolean; children: ReactNode }) {
165
- const ref = useRef<HTMLDetailsElement>(null);
166
- useEffect(() => {
167
- const close = (event: Event) => {
168
- if (event instanceof KeyboardEvent) {
169
- if (event.key === 'Escape' && ref.current?.open) { ref.current.open = false; ref.current.querySelector('summary')?.focus(); }
170
- } else if (event.target instanceof Node && !ref.current?.contains(event.target) && ref.current) ref.current.open = false;
171
- };
172
- document.addEventListener('pointerdown', close);
173
- document.addEventListener('keydown', close);
174
- return () => { document.removeEventListener('pointerdown', close); document.removeEventListener('keydown', close); };
175
- }, []);
176
- return <details ref={ref} className={styles.menu} data-tools={tools || undefined} onToggle={event => {
177
- if (event.currentTarget.open) for (const other of document.querySelectorAll<HTMLDetailsElement>('header details[open]')) if (other !== event.currentTarget) other.open = false;
178
- }}>
179
- <summary role="button" aria-label={label}>{working && <ActivityIndicator label="Specs running" />}{caption}{!tools && <ChevronDownIcon size={12} />}</summary>
180
- <div className={styles.dropdown}>{children}</div>
181
- </details>;
182
- }
183
-
184
234
  function SpecBreadcrumb() {
185
235
  const evidence = useEvidence();
186
236
  const { view } = useAtlas();
@@ -189,17 +239,39 @@ function SpecBreadcrumb() {
189
239
  const navigate = useNavigate();
190
240
  const kind = kindOfAddress(pathname);
191
241
  const chart = pathname.startsWith('/chart/') ? decodeURIComponent(pathname.split('/')[2] ?? '') : null;
192
- const options = kind ? evidence.files.filter(kind.accepts).map(file => ({ id: file, name: evidence.titleOf(file), running: (evidence.view.specs.get(outlinePath(file))?.summary.counts.running ?? 0) > 0 }))
193
- : [...(view?.charts.values() ?? [])].map(item => ({ id: item.id, name: item.name, running: (evidence.view.specs.get(outlinePath(item.file))?.summary.counts.running ?? 0) > 0 }));
242
+ const options = kind
243
+ ? evidence.files.filter(kind.accepts).map((file) => ({
244
+ id: file,
245
+ name: evidence.titleOf(file),
246
+ running: (evidence.view.specs.get(outlinePath(file))?.summary.counts.running ?? 0) > 0
247
+ }))
248
+ : [...(view?.charts.values() ?? [])].map((item) => ({
249
+ id: item.id,
250
+ name: item.name,
251
+ running: (evidence.view.specs.get(outlinePath(item.file))?.summary.counts.running ?? 0) > 0
252
+ }));
194
253
  if (!options.length) return null;
195
- const running = options.some(option => option.running);
196
- return <><span aria-hidden="true">›</span><label className={styles.picker} data-running={running || undefined}><span className="sr-only">Spec</span>
197
- {running && <span className={styles.pickerActivity}><ActivityIndicator label="Specs running" /></span>}
198
- <select aria-label="Spec" className={styles.select} value={kind ? search.spec ?? '' : chart ?? ''} onChange={event => {
199
- const value = event.target.value;
200
- if (kind) void navigate({ to: '/specs/$kind', params: { kind: kind.id }, search: value ? { spec: value } : {} });
201
- else if (value) void navigate({ to: '/chart/$chartId', params: { chartId: value }, search: {} });
202
- else void navigate({ to: '/', search: {} });
203
- }}><option value="">All specs</option>{options.map(option => <option key={option.id} value={option.id}>{option.name}{option.running ? ' · Running' : ''}</option>)}</select>
204
- </label></>;
254
+ return (
255
+ <NativeSelect
256
+ data-running={options.some((option) => option.running) || undefined}
257
+ leftSection={options.some((option) => option.running) ? <ActivityIndicator label="Specs running" /> : undefined}
258
+ aria-label="Spec"
259
+ maw="20rem"
260
+ value={kind ? (search.spec ?? '') : (chart ?? '')}
261
+ onChange={(event) => {
262
+ const value = event.target.value;
263
+ if (kind) void navigate({ to: '/specs/$kind', params: { kind: kind.id }, search: value ? { spec: value } : {} });
264
+ else if (value) void navigate({ to: '/chart/$chartId', params: { chartId: value }, search: {} });
265
+ else void navigate({ to: '/', search: {} });
266
+ }}
267
+ >
268
+ <option value="">All specs</option>
269
+ {options.map((option) => (
270
+ <option key={option.id} value={option.id}>
271
+ {option.name}
272
+ {option.running ? ' · Running' : ''}
273
+ </option>
274
+ ))}
275
+ </NativeSelect>
276
+ );
205
277
  }
@@ -1,50 +1,22 @@
1
+ import { Badge, Group } from '@mantine/core';
1
2
  import { KindIcon } from './labels';
2
- import styles from './Legend.module.css';
3
-
4
3
  export function Legend({ showTime }: { showTime: boolean }) {
5
4
  return (
6
- <div className={styles.legend} role="group" aria-label="Legend">
7
- <span className={styles.item}>
8
- <span className={styles.chip} data-kind="user">
9
- <KindIcon kind="user" size={11} />
10
- </span>
11
- User action
12
- </span>
13
- <span className={styles.item}>
14
- <span className={styles.chip} data-kind="system">
15
- <KindIcon kind="system" size={11} />
16
- </span>
5
+ <Group gap="xs" role="group" aria-label="Legend">
6
+ <Badge leftSection={<KindIcon kind="user" size={12} />}>User action</Badge>
7
+ <Badge color="gray" leftSection={<KindIcon kind="system" size={12} />}>
17
8
  System event
18
- </span>
9
+ </Badge>
19
10
  {showTime && (
20
- <span className={styles.item}>
21
- <span className={styles.chip} data-kind="time">
22
- <KindIcon kind="time" size={11} />
23
- </span>
11
+ <Badge color="gray" leftSection={<KindIcon kind="time" size={12} />}>
24
12
  Time passes
25
- </span>
13
+ </Badge>
26
14
  )}
27
- <span className={styles.item}>
28
- <span className={styles.chip} data-kind="none" />
29
- Not reached or not run
30
- </span>
31
- <span className={styles.sep} aria-hidden="true" />
32
- <span className={styles.item}>
33
- <span className={styles.frame} data-status="passed" />
34
- Working
35
- </span>
36
- <span className={styles.item}>
37
- <span className={styles.frame} data-status="flaky" />
38
- Unreliable
39
- </span>
40
- <span className={styles.item}>
41
- <span className={styles.frame} data-status="failed" />
42
- Broken
43
- </span>
44
- <span className={styles.item}>
45
- <span className={styles.frame} data-status="none" />
46
- Not checked
47
- </span>
48
- </div>
15
+ <Badge color="gray">Not reached or not run</Badge>
16
+ <Badge color="green">Working</Badge>
17
+ <Badge color="orange">Unreliable</Badge>
18
+ <Badge color="red">Broken</Badge>
19
+ <Badge color="gray">Not checked</Badge>
20
+ </Group>
49
21
  );
50
22
  }
@@ -1,11 +1,10 @@
1
- import { useEffect } from 'react';
2
- import { useQuery, useQueryClient } from '@tanstack/react-query';
3
1
  import { ProjectsSchema, type Project } from '@crvouga/atlas/schema';
4
- import { currentProject, runtimeConfig, runtimeUrl } from '../runtime';
5
- import { eventsAddress, listenLive, useLiveStatus } from '../live/live-stream';
2
+ import { Alert, Box, Button, NativeSelect, Text, VisuallyHidden } from '@mantine/core';
3
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
4
+ import { useEffect } from 'react';
6
5
  import { liveInvalidator } from '../live/live-query';
7
- import styles from './Header.module.css';
8
-
6
+ import { eventsAddress, listenLive, useLiveStatus } from '../live/live-stream';
7
+ import { currentProject, runtimeConfig, runtimeUrl } from '../runtime';
9
8
  /** Project changes navigate the page so queries, recordings and event streams cannot carry across projects. */
10
9
  export function selectProject(slug: string) {
11
10
  const url = new URL(window.location.href);
@@ -13,7 +12,6 @@ export function selectProject(slug: string) {
13
12
  url.hash = '#/';
14
13
  window.location.assign(url.href);
15
14
  }
16
-
17
15
  function useProjects() {
18
16
  const address = runtimeUrl(runtimeConfig()?.projectsUrl);
19
17
  const client = useQueryClient();
@@ -22,43 +20,80 @@ function useProjects() {
22
20
  const events = eventsAddress();
23
21
  if (!address || !events) return;
24
22
  const refresh = liveInvalidator(client, ['projects', address], 250);
25
- const stop = listenLive(events, event => { if (event.type === 'open' || event.type === 'projects' || event.type === 'report') refresh(); });
26
- return () => { stop(); refresh.cancel(); };
23
+ const stop = listenLive(events, (event) => {
24
+ if (event.type === 'open' || event.type === 'projects' || event.type === 'report') refresh();
25
+ });
26
+ return () => {
27
+ stop();
28
+ refresh.cancel();
29
+ };
27
30
  }, [address, client]);
28
- return useQuery<Project[]>({ queryKey: ['projects', address], enabled: !!address, staleTime: 10_000,
29
- refetchInterval: query => query.state.error?.message.includes('(404)') ? false : streaming ? 60_000 : 5000, refetchIntervalInBackground: true, retry: false,
31
+ return useQuery<Project[]>({
32
+ queryKey: ['projects', address],
33
+ enabled: !!address,
34
+ staleTime: 10000,
35
+ refetchInterval: (query) => (query.state.error?.message.includes('(404)') ? false : streaming ? 60000 : 5000),
36
+ refetchIntervalInBackground: true,
37
+ retry: false,
30
38
  queryFn: async ({ signal }) => {
31
39
  const response = await fetch(address!, { signal, cache: 'no-store' });
32
40
  if (!response.ok) throw new Error(`Projects unavailable (${response.status})`);
33
41
  return ProjectsSchema.parse(await response.json()).projects;
34
- } });
42
+ }
43
+ });
35
44
  }
36
-
37
45
  export function ProjectPicker() {
38
46
  const address = runtimeUrl(runtimeConfig()?.projectsUrl);
39
47
  const selected = currentProject();
40
48
  const query = useProjects();
41
49
  useEffect(() => {
42
- if (!selected && query.data?.length && !query.data.some(project => project.slug === 'default')) selectProject(query.data[0]!.slug);
50
+ if (!selected && query.data?.length && !query.data.some((project) => project.slug === 'default')) selectProject(query.data[0]!.slug);
43
51
  }, [selected, query.data]);
44
52
  if (!address) return null;
45
53
  const projects = query.data ?? [];
46
54
  const value = selected ?? 'default';
47
- return <label className={styles.picker} data-project="true" title={query.error ? 'Project switching unavailable. Open View options for details.' : 'Switch project'}>
48
- <span className="sr-only">Project</span>
49
- <select className={styles.select} value={value} disabled={!projects.length} onChange={event => selectProject(event.target.value)} aria-busy={query.isLoading}>
50
- {!projects.some(project => project.slug === value) && <option value={value}>{selected ?? (query.isLoading ? 'Loading projects…' : 'Default project')}</option>}
51
- {projects.map(project => <option key={project.slug} value={project.slug}>{project.name}</option>)}
52
- </select>
53
- </label>;
55
+ return (
56
+ <Box
57
+ data-project="true"
58
+ title={query.error ? 'Project switching unavailable. Open View options for details.' : 'Switch project'}
59
+ component="label"
60
+ >
61
+ <VisuallyHidden>Project</VisuallyHidden>
62
+ <NativeSelect
63
+ aria-label="Project"
64
+ maw={{ base: '8rem', sm: '14rem' }}
65
+ value={value}
66
+ disabled={!projects.length}
67
+ onChange={(event) => selectProject(event.target.value)}
68
+ aria-busy={query.isLoading}
69
+ >
70
+ {!projects.some((project) => project.slug === value) && (
71
+ <option value={value}>{selected ?? (query.isLoading ? 'Loading projects…' : 'Default project')}</option>
72
+ )}
73
+ {projects.map((project) => (
74
+ <option key={project.slug} value={project.slug}>
75
+ {project.name}
76
+ </option>
77
+ ))}
78
+ </NativeSelect>
79
+ </Box>
80
+ );
54
81
  }
55
-
56
82
  /** One actionable error in the options menu, without duplicating alerts across the navigation. */
57
83
  export function ProjectNotice() {
58
84
  const query = useProjects();
59
85
  if (!query.error) return null;
60
- return <div className={styles.notice} role="alert">
61
- <p>Project switching is unavailable. {query.error.message.includes('(404)') ? 'Restart the Atlas server to load project support.' : 'The last available project list is shown.'}</p>
62
- <button type="button" className={styles.sources} onClick={() => void query.refetch()}>Retry projects</button>
63
- </div>;
86
+ return (
87
+ <Alert role="alert" color={'red'} my="sm">
88
+ <Text component="div">
89
+ Project switching is unavailable.{' '}
90
+ {query.error.message.includes('(404)')
91
+ ? 'Restart the Atlas server to load project support.'
92
+ : 'The last available project list is shown.'}
93
+ </Text>
94
+ <Button type="button" onClick={() => void query.refetch()} variant="light">
95
+ Retry projects
96
+ </Button>
97
+ </Alert>
98
+ );
64
99
  }