@crvouga/atlas 0.10.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +7 -0
  3. package/dist/server/src/settings-page.d.ts +4 -4
  4. package/dist/server/src/settings-page.js +17 -78
  5. package/docs/reference/server.md +4 -4
  6. package/docs/ui.md +142 -0
  7. package/docs/workspaces.md +130 -0
  8. package/llms-full.txt +278 -0
  9. package/llms.txt +2 -0
  10. package/package.json +17 -10
  11. package/packages/visualizer/src/admin/SettingsPage.tsx +456 -0
  12. package/packages/visualizer/src/admin/main.tsx +11 -0
  13. package/packages/visualizer/src/app/AppShell.tsx +62 -38
  14. package/packages/visualizer/src/app/gazetteer.tsx +90 -83
  15. package/packages/visualizer/src/app/router.tsx +33 -15
  16. package/packages/visualizer/src/components/ActivityIndicator.tsx +4 -5
  17. package/packages/visualizer/src/components/DataIssues.tsx +36 -54
  18. package/packages/visualizer/src/components/Header.tsx +197 -125
  19. package/packages/visualizer/src/components/Legend.tsx +13 -41
  20. package/packages/visualizer/src/components/ProjectPicker.tsx +61 -26
  21. package/packages/visualizer/src/components/Screen.tsx +80 -71
  22. package/packages/visualizer/src/components/SpecLogo.tsx +15 -0
  23. package/packages/visualizer/src/components/Status.tsx +20 -34
  24. package/packages/visualizer/src/components/labels.tsx +8 -3
  25. package/packages/visualizer/src/components/spec-logos/APACHE-2.0.txt +206 -0
  26. package/packages/visualizer/src/components/spec-logos/CAMUNDA-MIT.txt +21 -0
  27. package/packages/visualizer/src/components/spec-logos/FILE-ICONS-ISC.txt +13 -0
  28. package/packages/visualizer/src/components/spec-logos/README.md +23 -0
  29. package/packages/visualizer/src/components/spec-logos/SIMPLE-ICONS-CC0.txt +30 -0
  30. package/packages/visualizer/src/components/spec-logos/alloy.svg +1 -0
  31. package/packages/visualizer/src/components/spec-logos/dmn.svg +3 -0
  32. package/packages/visualizer/src/components/spec-logos/gherkin.svg +1 -0
  33. package/packages/visualizer/src/components/spec-logos/openslo.svg +63 -0
  34. package/packages/visualizer/src/components/spec-logos/quint.svg +1 -0
  35. package/packages/visualizer/src/evidence/artifacts.tsx +263 -110
  36. package/packages/visualizer/src/evidence/chart-source.tsx +18 -0
  37. package/packages/visualizer/src/evidence/check-detail.tsx +283 -139
  38. package/packages/visualizer/src/evidence/decision-table.tsx +35 -26
  39. package/packages/visualizer/src/evidence/document-page.tsx +51 -29
  40. package/packages/visualizer/src/evidence/feature-page.tsx +121 -53
  41. package/packages/visualizer/src/evidence/marks.tsx +112 -79
  42. package/packages/visualizer/src/evidence/model-page.tsx +77 -46
  43. package/packages/visualizer/src/evidence/model-trace.tsx +55 -35
  44. package/packages/visualizer/src/evidence/overview.tsx +137 -69
  45. package/packages/visualizer/src/evidence/recording-page.tsx +70 -31
  46. package/packages/visualizer/src/evidence/recording.tsx +135 -68
  47. package/packages/visualizer/src/evidence/snapshot.tsx +99 -43
  48. package/packages/visualizer/src/evidence/source-text.tsx +33 -0
  49. package/packages/visualizer/src/evidence/sources-panel.tsx +138 -144
  50. package/packages/visualizer/src/evidence/spec-header.tsx +52 -36
  51. package/packages/visualizer/src/evidence/spec-index.tsx +61 -32
  52. package/packages/visualizer/src/evidence/spec-page.tsx +56 -25
  53. package/packages/visualizer/src/evidence/test-filter.tsx +38 -14
  54. package/packages/visualizer/src/explorer/Explorer.tsx +688 -261
  55. package/packages/visualizer/src/explorer/ExplorerIndex.tsx +136 -68
  56. package/packages/visualizer/src/explorer/RunsView.tsx +307 -94
  57. package/packages/visualizer/src/explorer/TestDetail.tsx +393 -118
  58. package/packages/visualizer/src/explorer/parts.tsx +35 -13
  59. package/packages/visualizer/src/find/Finder.tsx +106 -90
  60. package/packages/visualizer/src/find/open.ts +17 -20
  61. package/packages/visualizer/src/live/test-progress.tsx +60 -22
  62. package/packages/visualizer/src/looks/Stage.tsx +223 -110
  63. package/packages/visualizer/src/main.tsx +9 -11
  64. package/packages/visualizer/src/map/ChartMap.tsx +125 -69
  65. package/packages/visualizer/src/map/EventEdge.tsx +59 -37
  66. package/packages/visualizer/src/map/map.module.css +12 -546
  67. package/packages/visualizer/src/map/nodes.tsx +124 -70
  68. package/packages/visualizer/src/reachable.test.ts +3 -2
  69. package/packages/visualizer/src/spec-kind.ts +2 -2
  70. package/packages/visualizer/src/ui/grammars/ALLOY-MIT.txt +21 -0
  71. package/packages/visualizer/src/ui/grammars/QUINT-APACHE-2.0.txt +206 -0
  72. package/packages/visualizer/src/ui/grammars/README.md +14 -0
  73. package/packages/visualizer/src/ui/grammars/alloy.json +71 -0
  74. package/packages/visualizer/src/ui/grammars/quint.json +152 -0
  75. package/packages/visualizer/src/ui/highlight.ts +50 -0
  76. package/packages/visualizer/src/ui/theme.tsx +109 -0
  77. package/packages/visualizer/src/views/ChartPage.tsx +46 -26
  78. package/packages/visualizer/src/views/ChartTests.tsx +59 -22
  79. package/packages/visualizer/src/views/ProductPage.tsx +16 -15
  80. package/packages/visualizer/src/views/SidePanel.tsx +241 -131
  81. package/packages/visualizer/src/app/gazetteer.module.css +0 -107
  82. package/packages/visualizer/src/components/ActivityIndicator.module.css +0 -4
  83. package/packages/visualizer/src/components/DataIssues.module.css +0 -136
  84. package/packages/visualizer/src/components/Header.module.css +0 -114
  85. package/packages/visualizer/src/components/Legend.module.css +0 -84
  86. package/packages/visualizer/src/components/Screen.module.css +0 -312
  87. package/packages/visualizer/src/components/Status.module.css +0 -116
  88. package/packages/visualizer/src/components/icons.tsx +0 -156
  89. package/packages/visualizer/src/evidence/artifacts.module.css +0 -213
  90. package/packages/visualizer/src/evidence/evidence.module.css +0 -907
  91. package/packages/visualizer/src/evidence/recording.module.css +0 -460
  92. package/packages/visualizer/src/evidence/sources.module.css +0 -168
  93. package/packages/visualizer/src/evidence/spec-snapshot.tsx +0 -26
  94. package/packages/visualizer/src/explorer/explorer.module.css +0 -1238
  95. package/packages/visualizer/src/find/Finder.module.css +0 -168
  96. package/packages/visualizer/src/looks/stage.module.css +0 -300
  97. package/packages/visualizer/src/styles/global.css +0 -105
  98. package/packages/visualizer/src/styles/tokens.css +0 -80
  99. package/packages/visualizer/src/views/views.module.css +0 -546
@@ -0,0 +1,456 @@
1
+ import { IconArrowLeft } from '@tabler/icons-react';
2
+ import { ServerEditableSettingsSchema } from '@crvouga/atlas/schema';
3
+ /** @module Server settings interface
4
+ * Administrator credentials live only in this component; the server validates every request.
5
+ */
6
+ import {
7
+ Accordion,
8
+ Alert,
9
+ Anchor,
10
+ AppShell,
11
+ Badge,
12
+ Box,
13
+ Button,
14
+ Code,
15
+ Container,
16
+ Fieldset,
17
+ Group,
18
+ JsonInput,
19
+ NativeSelect,
20
+ NumberInput,
21
+ PasswordInput,
22
+ SimpleGrid,
23
+ Stack,
24
+ Text,
25
+ TextInput,
26
+ Title
27
+ } from '@mantine/core';
28
+ import { useState } from 'react';
29
+
30
+ type Settings = Record<string, unknown>;
31
+ type Backend = { id: string; label?: string; url: string; write?: boolean };
32
+ type Field = {
33
+ id: string;
34
+ label: string;
35
+ path: string[];
36
+ description?: string;
37
+ min?: number;
38
+ max?: number;
39
+ type?: 'number' | 'boolean' | 'branches';
40
+ };
41
+ const fields: Record<string, Field[]> = {
42
+ storage: [
43
+ { id: 'data', label: 'Local data folder', path: ['data'], description: 'Relative paths start from the server configuration directory.' }
44
+ ],
45
+ live: [
46
+ {
47
+ id: 'runTimeoutSeconds',
48
+ label: 'Missing heartbeat timeout (seconds)',
49
+ path: ['runTimeoutSeconds'],
50
+ type: 'number',
51
+ min: 10,
52
+ max: 3600,
53
+ description: 'Interrupt a run when its producer stops checking in. Default: 30 seconds.'
54
+ },
55
+ {
56
+ id: 'runStallSeconds',
57
+ label: 'No-progress timeout (seconds)',
58
+ path: ['runStallSeconds'],
59
+ type: 'number',
60
+ min: 0,
61
+ max: 86400,
62
+ description: 'Interrupt stalled runs. Use 0 to disable. Default: 900 seconds.'
63
+ },
64
+ {
65
+ id: 'refreshSeconds',
66
+ label: 'Storage refresh interval (seconds)',
67
+ path: ['refreshSeconds'],
68
+ type: 'number',
69
+ min: 1,
70
+ description: 'Check for reports written outside this node. Default: 10 seconds.'
71
+ },
72
+ {
73
+ id: 'heartbeatSeconds',
74
+ label: 'Client keepalive interval (seconds)',
75
+ path: ['heartbeatSeconds'],
76
+ type: 'number',
77
+ min: 1,
78
+ description: 'Keep browser connections open between updates. Default: 15 seconds.'
79
+ }
80
+ ],
81
+ rules: [
82
+ {
83
+ id: 'outlineBranch',
84
+ label: 'Preferred specification branch',
85
+ path: ['outlineBranch'],
86
+ description: 'Leave blank to prefer main, then master, then the newest available specs.'
87
+ },
88
+ {
89
+ id: 'branches',
90
+ label: 'Include branches',
91
+ path: ['include', 'branches'],
92
+ type: 'branches',
93
+ description: 'Comma-separated branch names. Leave blank to include every branch.'
94
+ },
95
+ { id: 'ignoreModified', label: 'Results from modified checkouts', path: ['include', 'ignoreModified'], type: 'boolean' }
96
+ ],
97
+ advanced: [
98
+ { id: 'maxObjectMiB', label: 'Maximum upload size (MiB)', path: ['limits', 'maxObjectMiB'], type: 'number', min: 0.001 },
99
+ { id: 'maxJsonMiB', label: 'Maximum JSON size (MiB)', path: ['limits', 'maxJsonMiB'], type: 'number', min: 0.001 },
100
+ { id: 'maxStreams', label: 'Maximum live connections', path: ['limits', 'maxStreams'], type: 'number', min: 1 },
101
+ { id: 'log', label: 'Request logging', path: ['log'], type: 'boolean' }
102
+ ]
103
+ };
104
+ function at(value: Settings, path: string[]): unknown {
105
+ return path.reduce<unknown>((held, key) => (held && typeof held === 'object' ? (held as Settings)[key] : undefined), value);
106
+ }
107
+ function updated(draft: Settings, path: string[], value: unknown): Settings {
108
+ const next = structuredClone(draft);
109
+ let held = next;
110
+ for (const key of path.slice(0, -1)) {
111
+ held[key] = held[key] && typeof held[key] === 'object' ? held[key] : {};
112
+ held = held[key] as Settings;
113
+ }
114
+ if (value === undefined) delete held[path.at(-1)!];
115
+ else held[path.at(-1)!] = value;
116
+ for (const key of ['include', 'limits']) if (next[key] && !Object.keys(next[key] as object).length) delete next[key];
117
+ return next;
118
+ }
119
+ /** Complete settings editor with revision conflicts, raw JSON, default values, and storage selection. */
120
+ export function SettingsPage() {
121
+ const [token, setToken] = useState('');
122
+ const [revision, setRevision] = useState<string>();
123
+ const [draft, setDraft] = useState<Settings>({});
124
+ const [active, setActive] = useState<Settings>({});
125
+ const [json, setJson] = useState('{}');
126
+ const [jsonError, setJsonError] = useState('');
127
+ const [busy, setBusy] = useState(false);
128
+ const [stale, setStale] = useState(false);
129
+ const [message, setMessage] = useState<{ text: string; error?: boolean; success?: boolean }>();
130
+ const params = new URLSearchParams(window.location.search);
131
+ const project = params.get('project');
132
+ const back =
133
+ project && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(project) && project.length <= 64
134
+ ? `/?project=${encodeURIComponent(project)}#/`
135
+ : '/';
136
+ const change = (next: Settings) => {
137
+ setDraft(next);
138
+ setJson(JSON.stringify(next, null, 2));
139
+ setJsonError('');
140
+ };
141
+ const backends = (draft.backends as Backend[] | undefined) ?? [];
142
+ const changeBackend = (index: number, patch: Partial<Backend>) =>
143
+ change({ ...draft, backends: backends.map((b, i) => (i === index ? { ...b, ...patch } : b)) });
144
+ const request = async (method: 'GET' | 'PUT') => {
145
+ if (busy || (method === 'PUT' && (!revision || stale))) return;
146
+ setBusy(true);
147
+ if (method === 'GET') setRevision(undefined);
148
+ try {
149
+ const headers: Record<string, string> = token ? { authorization: `Bearer ${token}` } : {};
150
+ let body: string | undefined;
151
+ if (method === 'PUT') {
152
+ const parsed = JSON.parse(json);
153
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('Settings must be a JSON object.');
154
+ headers['content-type'] = 'application/json';
155
+ headers['if-match'] = `"${revision}"`;
156
+ body = JSON.stringify(parsed);
157
+ }
158
+ const response = await fetch('/api/settings', { method, headers, body, cache: 'no-store' });
159
+ const data = await response.json();
160
+ if (!response.ok) {
161
+ if (response.status === 409 || response.status === 428) setStale(true);
162
+ throw new Error(data.detail || data.error || `Request failed (${response.status})`);
163
+ }
164
+ setRevision(data.revision);
165
+ setStale(false);
166
+ setActive(data.active);
167
+ change(data.settings);
168
+ setMessage({
169
+ text:
170
+ method === 'PUT' || data.restartRequired
171
+ ? 'Saved. Restart Atlas to apply.'
172
+ : 'Settings loaded. Blank fields use defaults after restart.',
173
+ success: method === 'PUT' || data.restartRequired
174
+ });
175
+ } catch (error) {
176
+ setMessage({
177
+ text:
178
+ error instanceof SyntaxError
179
+ ? 'The JSON configuration is invalid. Fix it before saving.'
180
+ : error instanceof Error
181
+ ? error.message
182
+ : String(error),
183
+ error: true
184
+ });
185
+ } finally {
186
+ setBusy(false);
187
+ }
188
+ };
189
+ const input = (field: Field) => {
190
+ const saved = at(draft, field.path),
191
+ current = at(active, field.path);
192
+ const description = (
193
+ <>
194
+ {field.description}
195
+ {current !== undefined && (
196
+ <Text component="span" display="block" size="xs">
197
+ Current: {Array.isArray(current) ? current.join(', ') || 'All branches' : String(current)}
198
+ </Text>
199
+ )}
200
+ </>
201
+ );
202
+ const props = { id: field.id, label: field.label, description };
203
+ if (field.type === 'number')
204
+ return (
205
+ <NumberInput
206
+ key={field.id}
207
+ {...props}
208
+ value={typeof saved === 'number' ? saved : ''}
209
+ min={field.min}
210
+ max={field.max}
211
+ allowDecimal={field.id !== 'maxStreams'}
212
+ onChange={(value) => change(updated(draft, field.path, value === '' ? undefined : Number(value)))}
213
+ />
214
+ );
215
+ if (field.type === 'boolean')
216
+ return (
217
+ <NativeSelect
218
+ key={field.id}
219
+ {...props}
220
+ value={saved === undefined ? '' : String(saved)}
221
+ onChange={(event) =>
222
+ change(updated(draft, field.path, event.currentTarget.value === '' ? undefined : event.currentTarget.value === 'true'))
223
+ }
224
+ data={
225
+ field.id === 'ignoreModified'
226
+ ? [
227
+ { value: '', label: 'Use default (include)' },
228
+ { value: 'false', label: 'Include' },
229
+ { value: 'true', label: 'Exclude' }
230
+ ]
231
+ : [
232
+ { value: '', label: 'Use default (enabled)' },
233
+ { value: 'true', label: 'Enabled' },
234
+ { value: 'false', label: 'Disabled' }
235
+ ]
236
+ }
237
+ />
238
+ );
239
+ return (
240
+ <TextInput
241
+ key={field.id}
242
+ {...props}
243
+ value={Array.isArray(saved) ? saved.join(', ') : String(saved ?? '')}
244
+ onChange={(event) => {
245
+ const text = event.currentTarget.value;
246
+ change(
247
+ updated(
248
+ draft,
249
+ field.path,
250
+ text === ''
251
+ ? undefined
252
+ : field.type === 'branches'
253
+ ? text
254
+ .split(',')
255
+ .map((item) => item.trim())
256
+ .filter(Boolean)
257
+ : text
258
+ )
259
+ );
260
+ }}
261
+ />
262
+ );
263
+ };
264
+ return (
265
+ <AppShell header={{ height: 64 }}>
266
+ <AppShell.Header>
267
+ <Group h="100%" px="lg" justify="space-between">
268
+ <Group>
269
+ <Anchor href={back} size="lg" fw={800}>
270
+ Atlas
271
+ </Anchor>
272
+ <Text c="dimmed">Settings</Text>
273
+ </Group>
274
+ <Anchor href={back}>
275
+ <Group gap="xs">
276
+ <IconArrowLeft size={16} aria-hidden="true" />
277
+ Back to reports
278
+ </Group>
279
+ </Anchor>
280
+ </Group>
281
+ </AppShell.Header>
282
+ <AppShell.Main>
283
+ <Container size="md" py="xl">
284
+ <Stack gap="xl">
285
+ <Box>
286
+ <Title order={1}>Atlas server settings</Title>
287
+ <Text c="dimmed" mt="xs">
288
+ Configure how this Atlas node stores reports and tracks test runs. These settings apply to every project on this node.
289
+ </Text>
290
+ </Box>
291
+ <Fieldset legend={<Group gap="xs">Administrator access {revision && <Badge color="green">Connected</Badge>}</Group>}>
292
+ <Stack>
293
+ <Text c="dimmed">
294
+ Enter an admin token to view and edit settings. For a local server without authentication, leave it blank.
295
+ </Text>
296
+ <PasswordInput
297
+ id="token"
298
+ label="Admin token"
299
+ autoComplete="off"
300
+ value={token}
301
+ onChange={(event) => setToken(event.currentTarget.value)}
302
+ description="Your token stays in this page and is never saved in browser storage."
303
+ />
304
+ <Button id="load" variant="filled" loading={busy} onClick={() => void request('GET')}>
305
+ {revision ? 'Reload settings' : 'Load settings'}
306
+ </Button>
307
+ </Stack>
308
+ </Fieldset>
309
+ {message && (
310
+ <Alert
311
+ id="status"
312
+ color={message.error ? 'red' : message.success ? 'green' : 'blue'}
313
+ role={message.error ? 'alert' : 'status'}
314
+ >
315
+ {message.text}
316
+ </Alert>
317
+ )}
318
+ {revision && (
319
+ <Box id="configuration">
320
+ <Stack gap="lg">
321
+ <Alert title="Changes apply after a server restart.">
322
+ Environment variables and command-line flags can override saved settings. Current values are shown under each field.
323
+ </Alert>
324
+ <Box
325
+ component="form"
326
+ onSubmit={(event) => {
327
+ event.preventDefault();
328
+ void request('PUT');
329
+ }}
330
+ >
331
+ <Stack gap="lg">
332
+ <Fieldset legend="Storage" disabled={busy}>
333
+ <Stack>
334
+ {fields.storage!.map(input)}
335
+ <NativeSelect
336
+ id="write"
337
+ label="Upload destination"
338
+ description={`Current: ${active.write ?? 'data'}. Reports are read from all configured storage locations.`}
339
+ value={String(draft.write ?? '')}
340
+ onChange={(event) => {
341
+ const next = updated(draft, ['write'], event.currentTarget.value || undefined);
342
+ change({
343
+ ...next,
344
+ ...(draft.backends !== undefined ? { backends: backends.map(({ write, ...backend }) => backend) } : {})
345
+ });
346
+ }}
347
+ >
348
+ <option value="">Use default destination</option>
349
+ <option value="data">Local data folder</option>
350
+ {backends.map((backend, i) => (
351
+ <option key={i} value={backend.id}>
352
+ {backend.label || backend.id}
353
+ </option>
354
+ ))}
355
+ {typeof draft.write === 'string' && draft.write !== 'data' && !backends.some((b) => b.id === draft.write) && (
356
+ <option value={draft.write}>{draft.write}</option>
357
+ )}
358
+ </NativeSelect>
359
+ <Title order={3}>Additional storage</Title>
360
+ <Text c="dimmed">
361
+ Add a folder, S3-compatible bucket, or another Atlas server. Keep credentials in the server environment.
362
+ </Text>
363
+ {!backends.length && <Text c="dimmed">No additional storage configured. Atlas uses its local data folder.</Text>}
364
+ {backends.map((backend, i) => (
365
+ <Fieldset key={i} legend="Storage location">
366
+ <Stack gap="sm">
367
+ <TextInput
368
+ label="Storage ID"
369
+ required
370
+ pattern="[a-z0-9-]+"
371
+ value={backend.id}
372
+ onChange={(event) => changeBackend(i, { id: event.currentTarget.value })}
373
+ />
374
+ <TextInput
375
+ label="Display name (optional)"
376
+ value={backend.label ?? ''}
377
+ onChange={(event) => changeBackend(i, { label: event.currentTarget.value || undefined })}
378
+ />
379
+ <TextInput
380
+ label="Location"
381
+ required
382
+ value={backend.url}
383
+ placeholder="s3://reports/atlas"
384
+ onChange={(event) => changeBackend(i, { url: event.currentTarget.value })}
385
+ />
386
+ <Button color="red" onClick={() => change({ ...draft, backends: backends.filter((_, at) => at !== i) })}>
387
+ Remove
388
+ </Button>
389
+ </Stack>
390
+ </Fieldset>
391
+ ))}
392
+ <Button onClick={() => change({ ...draft, backends: [...backends, { id: '', url: '' }] })}>Add storage</Button>
393
+ </Stack>
394
+ </Fieldset>
395
+ <Fieldset legend="Live updates" disabled={busy}>
396
+ <SimpleGrid cols={{ base: 1, sm: 2 }}>{fields.live!.map(input)}</SimpleGrid>
397
+ </Fieldset>
398
+ <Fieldset legend="Report rules" disabled={busy}>
399
+ <Stack>{fields.rules!.map(input)}</Stack>
400
+ </Fieldset>
401
+ <Fieldset legend="Advanced" disabled={busy}>
402
+ <Stack>
403
+ <SimpleGrid cols={{ base: 1, sm: 2 }}>{fields.advanced!.map(input)}</SimpleGrid>
404
+ <Accordion variant="separated">
405
+ <Accordion.Item value="json">
406
+ <Accordion.Control>Edit configuration as JSON</Accordion.Control>
407
+ <Accordion.Panel>
408
+ <JsonInput
409
+ id="settings"
410
+ label="Storage and report settings (JSON)"
411
+ autosize
412
+ minRows={10}
413
+ formatOnBlur
414
+ value={json}
415
+ error={jsonError || undefined}
416
+ onChange={(value) => {
417
+ setJson(value);
418
+ try {
419
+ const parsed = ServerEditableSettingsSchema.parse(JSON.parse(value));
420
+ setDraft(parsed);
421
+ setJsonError('');
422
+ } catch {
423
+ setJsonError('Settings must be a valid JSON object.');
424
+ }
425
+ }}
426
+ />
427
+ </Accordion.Panel>
428
+ </Accordion.Item>
429
+ <Accordion.Item value="active">
430
+ <Accordion.Control>View currently active settings</Accordion.Control>
431
+ <Accordion.Panel>
432
+ <Code block>{JSON.stringify(active, null, 2)}</Code>
433
+ </Accordion.Panel>
434
+ </Accordion.Item>
435
+ </Accordion>
436
+ </Stack>
437
+ </Fieldset>
438
+ <Group>
439
+ <Button id="save" type="submit" variant="filled" loading={busy} disabled={stale || !!jsonError}>
440
+ Save changes
441
+ </Button>
442
+ <Text size="xs" c="dimmed">
443
+ Saved to <Code>atlas.server.json</Code>. Restart Atlas to apply.
444
+ </Text>
445
+ </Group>
446
+ </Stack>
447
+ </Box>
448
+ </Stack>
449
+ </Box>
450
+ )}
451
+ </Stack>
452
+ </Container>
453
+ </AppShell.Main>
454
+ </AppShell>
455
+ );
456
+ }
@@ -0,0 +1,11 @@
1
+ import '@mantine/core/styles.css';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { AtlasThemeProvider } from '../ui/theme';
4
+ import { SettingsPage } from './SettingsPage';
5
+ const root = document.getElementById('root');
6
+ if (root)
7
+ createRoot(root).render(
8
+ <AtlasThemeProvider>
9
+ <SettingsPage />
10
+ </AtlasThemeProvider>
11
+ );
@@ -1,52 +1,88 @@
1
+ import { isStatechartFile } from '@crvouga/atlas/schema';
2
+ import { Alert, Box, Burger, Button, Container, Group, AppShell as MantineAppShell, ScrollArea, Stack, Text } from '@mantine/core';
1
3
  import { Outlet, useLocation, useSearch } from '@tanstack/react-router';
2
- import { lazy, Suspense } from 'react';
3
-
4
- import { LiveRunsProvider } from '../live/live-runs';
4
+ import { lazy, Suspense, useEffect, useState } from 'react';
5
5
  import { Header } from '../components/Header';
6
- import { EvidenceProvider } from '../evidence/evidence-context';
6
+ import { EvidenceProvider, useEvidence } from '../evidence/evidence-context';
7
7
  import { SourcesPanel } from '../evidence/sources-panel';
8
8
  import { SpecIndex } from '../evidence/spec-index';
9
- import { kindOfAddress } from '../spec-kind';
10
- import styles from '../views/views.module.css';
11
- import { AtlasProvider, useAtlas } from './atlas-context';
12
- import { Gazetteer } from './gazetteer';
13
- import { useEvidence } from '../evidence/evidence-context';
14
- import { isStatechartFile } from '@crvouga/atlas/schema';
15
9
  import { Explorer } from '../explorer/Explorer';
16
10
  import { ProjectIndex } from '../explorer/ExplorerIndex';
17
11
  import { TestCatalogProvider } from '../explorer/catalog';
18
-
19
- // The finder brings every spec parser with it. It loads as soon as the page has drawn, long before anyone presses ⌘K.
12
+ import { LiveRunsProvider } from '../live/live-runs';
13
+ import { kindOfAddress } from '../spec-kind';
14
+ import { AtlasProvider, useAtlas } from './atlas-context';
15
+ import { Gazetteer } from './gazetteer';
20
16
  const loadFinder = () => import('../find/Finder').then((m) => ({ default: m.Finder }));
21
17
  const Finder = lazy(loadFinder);
22
18
  if (typeof window !== 'undefined') setTimeout(() => void loadFinder(), 0);
23
-
24
19
  function Index() {
25
20
  const evidence = useEvidence();
26
21
  const { pathname } = useLocation();
27
22
  const kind = kindOfAddress(pathname);
28
- return kind ? <SpecIndex kind={kind} /> : pathname.startsWith('/specs/') ? <nav aria-label="Specs" /> : evidence.ready && !evidence.files.some(isStatechartFile) ? <ProjectIndex /> : <Gazetteer />;
23
+ return kind ? (
24
+ <SpecIndex kind={kind} />
25
+ ) : pathname.startsWith('/specs/') ? (
26
+ <Box component="nav" aria-label="Specs" />
27
+ ) : evidence.ready && !evidence.files.some(isStatechartFile) ? (
28
+ <ProjectIndex />
29
+ ) : (
30
+ <Gazetteer />
31
+ );
29
32
  }
30
-
31
33
  function Body() {
32
34
  const evidence = useEvidence();
33
35
  const { view, spec } = useAtlas();
34
36
  const { pathname } = useLocation();
35
37
  if (pathname === '/' && evidence.ready && !evidence.filesPending && !evidence.files.some(isStatechartFile)) return <Explorer />;
36
- if (!pathname.startsWith('/specs/') && !view && spec.error) {
38
+ if (!pathname.startsWith('/specs/') && !view && spec.error)
37
39
  return (
38
- <div className={styles.message} role="alert">
39
- <h1>The spec couldn’t be loaded</h1>
40
- <p>{spec.error}</p>
41
- <button type="button" className={styles.button} onClick={spec.retry}>
42
- Try again
43
- </button>
44
- </div>
40
+ <Container py="xl">
41
+ <Alert color="red" title="The spec couldn’t be loaded">
42
+ <Stack gap="sm">
43
+ <Text>{spec.error}</Text>
44
+ <Button onClick={spec.retry}>Try again</Button>
45
+ </Stack>
46
+ </Alert>
47
+ </Container>
45
48
  );
46
- }
47
49
  return <Outlet />;
48
50
  }
49
-
51
+ function Layout() {
52
+ const [opened, setOpened] = useState(false);
53
+ const location = useLocation();
54
+ useEffect(() => setOpened(false), [location.href]);
55
+ return (
56
+ <MantineAppShell header={{ height: 64 }} navbar={{ width: 288, breakpoint: 'md', collapsed: { mobile: !opened } }} padding={0}>
57
+ <MantineAppShell.Header>
58
+ <Group h="100%" px="md" gap="sm" wrap="nowrap">
59
+ <Burger
60
+ aria-expanded={opened}
61
+ aria-controls="atlas-navigation"
62
+ opened={opened}
63
+ onClick={() => setOpened((v) => !v)}
64
+ hiddenFrom="md"
65
+ size="sm"
66
+ aria-label="Toggle navigation"
67
+ />
68
+ <Header />
69
+ </Group>
70
+ </MantineAppShell.Header>
71
+ <MantineAppShell.Navbar id="atlas-navigation" component="div">
72
+ <ScrollArea h="100%">
73
+ <Index />
74
+ </ScrollArea>
75
+ </MantineAppShell.Navbar>
76
+ <MantineAppShell.Main>
77
+ <Body />
78
+ </MantineAppShell.Main>
79
+ <SourcesPanel />
80
+ <Suspense fallback={null}>
81
+ <Finder />
82
+ </Suspense>
83
+ </MantineAppShell>
84
+ );
85
+ }
50
86
  export function AppShell() {
51
87
  const { run } = useSearch({ strict: false });
52
88
  return (
@@ -54,19 +90,7 @@ export function AppShell() {
54
90
  <AtlasProvider run={run}>
55
91
  <EvidenceProvider>
56
92
  <TestCatalogProvider>
57
- <div className={styles.app}>
58
- <Header />
59
- <div className={styles.frame}>
60
- <Index />
61
- <main className={styles.main}>
62
- <Body />
63
- </main>
64
- </div>
65
- <SourcesPanel />
66
- <Suspense fallback={null}>
67
- <Finder />
68
- </Suspense>
69
- </div>
93
+ <Layout />
70
94
  </TestCatalogProvider>
71
95
  </EvidenceProvider>
72
96
  </AtlasProvider>