@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,18 +1,15 @@
1
+ import { IconArrowLeft } from '@tabler/icons-react';
2
+ import { Alert, Box, Button, Flex, Group, Image, List, ScrollArea, Slider, Stack, Tabs, Text, Title } from '@mantine/core';
1
3
  import { useEffect, useRef, useState, type ReactNode, type RefObject } from 'react';
2
-
3
- import { ChevronIcon } from '../components/icons';
4
+ import { IconChevronRight as ChevronIcon } from '@tabler/icons-react';
4
5
  import { finderBlocksKeys } from '../find/open';
5
6
  import type { FilmLook, Looks, MomentLook } from './collect';
6
- import styles from './stage.module.css';
7
-
8
7
  export type LookMode = 'pictures' | 'film' | 'timeline';
9
-
10
8
  const MODE_LABEL: Record<LookMode, string> = {
11
9
  pictures: 'Pictures',
12
10
  film: 'Video',
13
11
  timeline: 'Timeline'
14
12
  };
15
-
16
13
  type Props = {
17
14
  looks: Looks;
18
15
  /** Which kind to open when a run recorded more than one. */
@@ -31,7 +28,6 @@ type Props = {
31
28
  failure?: boolean;
32
29
  onFailure?: () => void;
33
30
  };
34
-
35
31
  function modesOf(looks: Looks): LookMode[] {
36
32
  const modes: LookMode[] = [];
37
33
  if (looks.pictures.length) modes.push('pictures');
@@ -39,25 +35,21 @@ function modesOf(looks: Looks): LookMode[] {
39
35
  if (looks.moments.length) modes.push('timeline');
40
36
  return modes;
41
37
  }
42
-
43
38
  function choose(prefer: LookMode, modes: readonly LookMode[], requested: LookMode | null | undefined): LookMode | null {
44
39
  if (requested && modes.includes(requested)) return requested;
45
40
  if (modes.includes(prefer)) return prefer;
46
41
  return modes[0] ?? null;
47
42
  }
48
-
49
43
  function clock(ms: number): string {
50
44
  const seconds = Math.max(0, Math.round(ms / 1000));
51
45
  const minutes = Math.floor(seconds / 60);
52
46
  const rest = seconds % 60;
53
47
  return minutes > 0 ? `${minutes}:${String(rest).padStart(2, '0')}` : `${rest}s`;
54
48
  }
55
-
56
49
  function pauseFor(mode: LookMode, moment: MomentLook | undefined): number {
57
50
  if (mode === 'pictures') return 1400;
58
51
  return Math.max(100, Math.min(moment?.pauseMs ?? 700, 2000));
59
52
  }
60
-
61
53
  /**
62
54
  * One player for whatever a runner captured: stills, a video, or a timeline of moments.
63
55
  * Kinds the run did not record are absent. When several were recorded, each is a full view of the same evidence.
@@ -66,7 +58,7 @@ export function Stage({ looks, prefer, empty, notice, moment, onMoment, renderMo
66
58
  const modes = modesOf(looks);
67
59
  const [localMode, setLocalMode] = useState<LookMode | null>(null);
68
60
  const [playing, setPlaying] = useState(false);
69
- const active = choose(prefer, modes, onMode ? mode ?? null : localMode);
61
+ const active = choose(prefer, modes, onMode ? (mode ?? null) : localMode);
70
62
  const pickMode = (next: LookMode) => {
71
63
  setPlaying(false);
72
64
  if (onMode) onMode(next);
@@ -79,11 +71,9 @@ export function Stage({ looks, prefer, empty, notice, moment, onMoment, renderMo
79
71
  const videoRef = useRef<HTMLVideoElement>(null);
80
72
  const onMomentRef = useRef(onMoment);
81
73
  onMomentRef.current = onMoment;
82
-
83
74
  const count = active === 'pictures' ? looks.pictures.length : active === 'film' ? looks.films.length : looks.moments.length;
84
75
  const timelineIndex = moment ?? localMoment;
85
76
  const index = Math.max(0, Math.min(count - 1, active === 'pictures' ? picture : active === 'film' ? film : timelineIndex));
86
-
87
77
  const select = (next: number) => {
88
78
  const clamped = Math.max(0, Math.min(count - 1, next));
89
79
  if (active === 'pictures') setPicture(clamped);
@@ -93,11 +83,9 @@ export function Stage({ looks, prefer, empty, notice, moment, onMoment, renderMo
93
83
  };
94
84
  const selectRef = useRef(select);
95
85
  selectRef.current = select;
96
-
97
86
  useEffect(() => {
98
87
  setPlaying(false);
99
88
  }, [active]);
100
-
101
89
  useEffect(() => {
102
90
  if (!playing || !active || active === 'film' || count < 2) return;
103
91
  if (index >= count - 1) {
@@ -108,7 +96,6 @@ export function Stage({ looks, prefer, empty, notice, moment, onMoment, renderMo
108
96
  const timer = setTimeout(() => selectRef.current(index + 1), pauseFor(active, current));
109
97
  return () => clearTimeout(timer);
110
98
  }, [playing, active, index, count, looks.moments]);
111
-
112
99
  useEffect(() => {
113
100
  if (!active || count < 2) return;
114
101
  const onKey = (event: KeyboardEvent) => {
@@ -118,7 +105,16 @@ export function Stage({ looks, prefer, empty, notice, moment, onMoment, renderMo
118
105
  if (!root || (focused instanceof HTMLElement && focused !== document.body && !root.contains(focused))) return;
119
106
  const target = event.target;
120
107
  if (target instanceof HTMLElement && (target.isContentEditable || target.closest('input, textarea, select'))) return;
121
- const next = event.key === 'ArrowRight' ? index + 1 : event.key === 'ArrowLeft' ? index - 1 : event.key === 'Home' ? 0 : event.key === 'End' ? count - 1 : null;
108
+ const next =
109
+ event.key === 'ArrowRight'
110
+ ? index + 1
111
+ : event.key === 'ArrowLeft'
112
+ ? index - 1
113
+ : event.key === 'Home'
114
+ ? 0
115
+ : event.key === 'End'
116
+ ? count - 1
117
+ : null;
122
118
  if (next === null || next === index || next < 0 || next >= count) return;
123
119
  event.preventDefault();
124
120
  setPlaying(false);
@@ -127,112 +123,178 @@ export function Stage({ looks, prefer, empty, notice, moment, onMoment, renderMo
127
123
  window.addEventListener('keydown', onKey);
128
124
  return () => window.removeEventListener('keydown', onKey);
129
125
  }, [active, index, count]);
130
-
131
126
  if (!active) {
132
127
  return (
133
- <div className={styles.stage}>
134
- {notice && <p className={styles.notice}>{notice}</p>}
135
- {empty ?? <p className={styles.quiet}>Nothing was captured of this.</p>}
136
- </div>
128
+ <Stack gap="sm">
129
+ {notice && (
130
+ <Alert color={'blue'} my="sm">
131
+ {notice}
132
+ </Alert>
133
+ )}
134
+ {empty ?? (
135
+ <Text component="div" c="dimmed">
136
+ Nothing was captured of this.
137
+ </Text>
138
+ )}
139
+ </Stack>
137
140
  );
138
141
  }
139
-
140
142
  const showTransport = count > 1;
141
143
  const showPlay = showTransport && active !== 'film';
142
144
  const shot = looks.pictures[index];
143
145
  const clip = looks.films[index];
144
146
  const step = looks.moments[index];
145
147
  const noun = active === 'pictures' ? 'Picture' : active === 'film' ? 'Video' : 'Moment';
146
-
147
148
  return (
148
- <section ref={sectionRef} className={styles.stage} aria-label="What it looked like">
149
+ <Box ref={sectionRef} aria-label="What it looked like" component="section" mb="lg">
149
150
  {modes.length > 1 && (
150
- <div className={styles.modes} role="tablist" aria-label="Captured evidence">
151
- {modes.map((item) => (
152
- <button key={item} type="button" role="tab" id={`look-${item}`} aria-selected={item === active} onClick={() => pickMode(item)}>
153
- {MODE_LABEL[item]}
154
- </button>
155
- ))}
156
- </div>
151
+ <Tabs value={active} onChange={(value) => pickMode(value as LookMode)} mb="md">
152
+ <Tabs.List aria-label="Captured evidence">
153
+ {modes.map((item) => (
154
+ <Tabs.Tab key={item} value={item} id={`look-${item}`}>
155
+ {MODE_LABEL[item]}
156
+ </Tabs.Tab>
157
+ ))}
158
+ </Tabs.List>
159
+ </Tabs>
157
160
  )}
158
- {notice && <p className={styles.notice}>{notice}</p>}
159
- <div className={styles.viewport}>
161
+ {notice && (
162
+ <Alert color={'blue'} my="sm">
163
+ {notice}
164
+ </Alert>
165
+ )}
166
+ <Stack gap="sm" miw={0}>
160
167
  {active === 'pictures' && shot && (
161
168
  <>
162
- <img className={styles.shot} src={shot.src} alt={shot.alt} />
163
- <p className={styles.caption}>{shot.label}</p>
169
+ <Image src={shot.src} alt={shot.alt} fit="contain" maw="100%" mah="60vh" />
170
+ <Flex gap="xs" align="center" wrap="wrap" c="dimmed">
171
+ {shot.label}
172
+ </Flex>
164
173
  </>
165
174
  )}
166
175
  {active === 'film' && clip && <Film film={clip} videoRef={videoRef} />}
167
- {active === 'timeline' && step && (
168
- renderMoment ? (
169
- <div className={styles.body}>{renderMoment(index)}</div>
176
+ {active === 'timeline' &&
177
+ step &&
178
+ (renderMoment ? (
179
+ <Stack gap="sm" miw={0}>
180
+ {renderMoment(index)}
181
+ </Stack>
170
182
  ) : step.thumb ? (
171
- <figure className={styles.story}>
183
+ <Box component="figure" m={0}>
172
184
  <Still src={step.thumb} point={step.point} />
173
- <figcaption className={styles.storyCaption} data-status={step.status ?? undefined}>
174
- {step.caption && <span>{step.caption}</span>}
175
- <strong>{step.label}</strong>
176
- </figcaption>
177
- </figure>
185
+ <Box data-status={step.status ?? undefined} component="figcaption">
186
+ {step.caption && (
187
+ <Text span inherit>
188
+ {step.caption}
189
+ </Text>
190
+ )}
191
+ <Text span inherit fw={600}>
192
+ {step.label}
193
+ </Text>
194
+ </Box>
195
+ </Box>
178
196
  ) : (
179
- <article className={styles.moment} data-status={step.status ?? undefined}>
180
- {step.caption && <p>{step.caption}</p>}
181
- <h3>{step.label}</h3>
182
- </article>
183
- )
184
- )}
185
- </div>
197
+ <Box data-status={step.status ?? undefined} component="article">
198
+ {step.caption && <Text component="div">{step.caption}</Text>}
199
+ <Title order={3} mb="sm">
200
+ {step.label}
201
+ </Title>
202
+ </Box>
203
+ ))}
204
+ </Stack>
186
205
  {showTransport && (
187
- <div className={styles.transport}>
206
+ <Flex gap="xs" align="center" wrap="wrap">
188
207
  {showPlay && (
189
- <button
208
+ <Button
190
209
  type="button"
191
- className={styles.control}
192
210
  onClick={() => {
193
211
  if (!playing && index === count - 1) select(0);
194
212
  setPlaying((value) => !value);
195
213
  }}
214
+ variant="light"
196
215
  >
197
216
  {playing ? 'Pause' : 'Play'}
198
- </button>
217
+ </Button>
199
218
  )}
200
- <button type="button" className={`${styles.control} ${styles.stepper}`} aria-label={`Previous ${noun.toLowerCase()}`} disabled={index === 0} onClick={() => { setPlaying(false); select(index - 1); }}>
201
- <ChevronIcon size={14} className={styles.back} />
202
- </button>
203
- <button type="button" className={`${styles.control} ${styles.stepper}`} aria-label={`Next ${noun.toLowerCase()}`} disabled={index >= count - 1} onClick={() => { setPlaying(false); select(index + 1); }}>
204
- <ChevronIcon size={14} />
205
- </button>
206
- <input
207
- className={styles.scrub}
208
- type="range"
219
+ <Button
220
+ type="button"
221
+ aria-label={`Previous ${noun.toLowerCase()}`}
222
+ disabled={index === 0}
223
+ onClick={() => {
224
+ setPlaying(false);
225
+ select(index - 1);
226
+ }}
227
+ variant="light"
228
+ >
229
+ <Text span aria-hidden="true">
230
+ <IconArrowLeft size={16} aria-hidden="true" />
231
+ </Text>
232
+ </Button>
233
+ <Button
234
+ type="button"
235
+ aria-label={`Next ${noun.toLowerCase()}`}
236
+ disabled={index >= count - 1}
237
+ onClick={() => {
238
+ setPlaying(false);
239
+ select(index + 1);
240
+ }}
241
+ variant="light"
242
+ >
243
+ <ChevronIcon aria-hidden="true" size={14} />
244
+ </Button>
245
+ <Slider
209
246
  aria-label={noun}
210
247
  min={0}
211
248
  max={count - 1}
212
249
  value={index}
213
- onChange={(event) => {
250
+ onChange={(value) => {
214
251
  setPlaying(false);
215
- select(Number(event.target.value));
252
+ select(value);
216
253
  }}
254
+ flex={1}
217
255
  />
218
- <span className={styles.position}>
256
+ <Text span inherit c="dimmed">
219
257
  {noun} {index + 1} of {count}
220
- </span>
258
+ </Text>
221
259
  {active === 'timeline' && onFailure && (
222
- <button type="button" className={styles.control} disabled={!failure} onClick={onFailure}>
260
+ <Button type="button" disabled={!failure} onClick={onFailure} variant="light">
223
261
  Next failure
224
- </button>
262
+ </Button>
225
263
  )}
226
- </div>
264
+ </Flex>
265
+ )}
266
+ {active === 'pictures' && (
267
+ <Strip
268
+ items={looks.pictures}
269
+ index={index}
270
+ onSelect={(next) => {
271
+ setPlaying(false);
272
+ select(next);
273
+ }}
274
+ />
275
+ )}
276
+ {active === 'film' && (
277
+ <Strip
278
+ items={looks.films.map((item) => ({ id: item.id, label: item.label, thumb: item.poster }))}
279
+ index={index}
280
+ onSelect={(next) => select(next)}
281
+ />
282
+ )}
283
+ {active === 'timeline' && (
284
+ <Strip
285
+ items={looks.moments}
286
+ index={index}
287
+ onSelect={(next) => {
288
+ setPlaying(false);
289
+ select(next);
290
+ }}
291
+ />
227
292
  )}
228
- {active === 'pictures' && <Strip items={looks.pictures} index={index} onSelect={(next) => { setPlaying(false); select(next); }} />}
229
- {active === 'film' && <Strip items={looks.films.map((item) => ({ id: item.id, label: item.label, thumb: item.poster }))} index={index} onSelect={(next) => select(next)} />}
230
- {active === 'timeline' && <Strip items={looks.moments} index={index} onSelect={(next) => { setPlaying(false); select(next); }} />}
231
293
  {active === 'film' && clip && clip.chapters.length > 0 && (
232
- <ol className={styles.chapters} aria-label="Moments in the video">
294
+ <List aria-label="Moments in the video" spacing="xs" listStyleType="none" type="ordered">
233
295
  {clip.chapters.map((chapter) => (
234
- <li key={`${chapter.atMs}:${chapter.label}`}>
235
- <button
296
+ <List.Item key={`${chapter.atMs}:${chapter.label}`}>
297
+ <Button
236
298
  type="button"
237
299
  onClick={() => {
238
300
  const node = videoRef.current;
@@ -240,24 +302,23 @@ export function Stage({ looks, prefer, empty, notice, moment, onMoment, renderMo
240
302
  node.currentTime = chapter.atMs / 1000;
241
303
  void node.play().catch(() => undefined);
242
304
  }}
305
+ variant="light"
243
306
  >
244
307
  {clock(chapter.atMs)} {chapter.label}
245
- </button>
246
- </li>
308
+ </Button>
309
+ </List.Item>
247
310
  ))}
248
- </ol>
311
+ </List>
249
312
  )}
250
- </section>
313
+ </Box>
251
314
  );
252
315
  }
253
-
254
316
  function Film({ film, videoRef }: { film: FilmLook; videoRef: RefObject<HTMLVideoElement | null> }) {
255
317
  return (
256
318
  <>
257
- <video
319
+ <Box
258
320
  key={film.src}
259
321
  ref={videoRef}
260
- className={styles.film}
261
322
  src={film.src}
262
323
  poster={film.poster ?? undefined}
263
324
  controls
@@ -268,55 +329,107 @@ function Film({ film, videoRef }: { film: FilmLook; videoRef: RefObject<HTMLVide
268
329
  const node = event.currentTarget;
269
330
  if (node.videoWidth > 0 && node.videoHeight > 0) node.style.setProperty('--ar', String(node.videoWidth / node.videoHeight));
270
331
  }}
332
+ component="video"
333
+ maw="100%"
334
+ w="100%"
271
335
  >
272
336
  {film.captions && <track kind="captions" src={film.captions} srcLang="en" label="Steps" default />}
273
- </video>
274
- <p className={styles.caption}>
337
+ </Box>
338
+ <Flex gap="xs" align="center" wrap="wrap" c="dimmed">
275
339
  {film.label}
276
340
  {film.durationMs !== null && ` · ${clock(film.durationMs)}`}
277
- </p>
341
+ </Flex>
278
342
  </>
279
343
  );
280
344
  }
281
-
282
345
  /** A still, with a mark where the moment happened when the run recorded a point on it. */
283
- function Still({ src, point }: { src: string; point?: { x: number; y: number } | null }) {
284
- const [size, setSize] = useState<{ w: number; h: number } | null>(null);
346
+ function Still({
347
+ src,
348
+ point
349
+ }: {
350
+ src: string;
351
+ point?: {
352
+ x: number;
353
+ y: number;
354
+ } | null;
355
+ }) {
356
+ const [size, setSize] = useState<{
357
+ w: number;
358
+ h: number;
359
+ } | null>(null);
285
360
  const placed = point && size && point.x >= 0 && point.y >= 0 && point.x <= size.w && point.y <= size.h;
286
361
  return (
287
- <div className={styles.shotFrame}>
288
- <img
289
- className={styles.shot}
362
+ <Box pos="relative" w="fit-content" maw="100%" mx="auto">
363
+ <Image
290
364
  src={src}
291
365
  alt=""
292
366
  onLoad={(event) => setSize({ w: event.currentTarget.naturalWidth, h: event.currentTarget.naturalHeight })}
367
+ fit="contain"
368
+ maw="100%"
369
+ mah="60vh"
293
370
  />
294
371
  {placed && (
295
- <span
296
- className={styles.mark}
297
- style={{ left: `${(point.x / size.w) * 100}%`, top: `${(point.y / size.h) * 100}%` }}
372
+ <Box
373
+ component="span"
374
+ pos="absolute"
375
+ left={`${(point.x / size.w) * 100}%`}
376
+ top={`${(point.y / size.h) * 100}%`}
377
+ w="1rem"
378
+ h="1rem"
379
+ bdrs="xl"
380
+ bg="blue.6"
381
+ opacity={0.6}
298
382
  aria-hidden="true"
299
383
  />
300
384
  )}
301
- </div>
385
+ </Box>
302
386
  );
303
387
  }
304
-
305
- function Strip({ items, index, onSelect }: { items: readonly { id: string; label: string; thumb: string | null }[]; index: number; onSelect: (index: number) => void }) {
388
+ function Strip({
389
+ items,
390
+ index,
391
+ onSelect
392
+ }: {
393
+ items: readonly {
394
+ id: string;
395
+ label: string;
396
+ thumb: string | null;
397
+ }[];
398
+ index: number;
399
+ onSelect: (index: number) => void;
400
+ }) {
306
401
  const selected = useRef<HTMLButtonElement>(null);
307
402
  useEffect(() => {
308
403
  selected.current?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
309
404
  }, [index]);
310
405
  if (items.length < 2) return null;
311
406
  return (
312
- <ol className={styles.strip} aria-label="Captured frames">
313
- {items.map((item, itemIndex) => (
314
- <li key={item.id}>
315
- <button ref={itemIndex === index ? selected : undefined} type="button" aria-current={itemIndex === index} aria-label={item.label} title={item.label} onClick={() => onSelect(itemIndex)}>
316
- {item.thumb ? <img src={item.thumb} alt="" /> : <span className={styles.chip}>{item.label}</span>}
317
- </button>
318
- </li>
319
- ))}
320
- </ol>
407
+ <ScrollArea type="auto" mt="sm">
408
+ <Group gap="xs" wrap="nowrap" aria-label="Captured frames">
409
+ {items.map((item, itemIndex) => (
410
+ <Box key={item.id}>
411
+ <Button
412
+ ref={itemIndex === index ? selected : undefined}
413
+ type="button"
414
+ aria-current={itemIndex === index}
415
+ aria-label={item.label}
416
+ title={item.label}
417
+ onClick={() => onSelect(itemIndex)}
418
+ variant={itemIndex === index ? 'light' : 'default'}
419
+ h="auto"
420
+ p="xs"
421
+ >
422
+ {item.thumb ? (
423
+ <Image src={item.thumb} alt="" fit="contain" w="4rem" h="3rem" />
424
+ ) : (
425
+ <Text span inherit>
426
+ {item.label}
427
+ </Text>
428
+ )}
429
+ </Button>
430
+ </Box>
431
+ ))}
432
+ </Group>
433
+ </ScrollArea>
321
434
  );
322
435
  }
@@ -1,18 +1,16 @@
1
- import './styles/global.css';
2
- import '@xyflow/react/dist/base.css';
3
-
4
- import * as Tooltip from '@radix-ui/react-tooltip';
1
+ import '@mantine/core/styles.css';
2
+ import '@mantine/code-highlight/styles.css';
3
+ import '@mantine/spotlight/styles.css';
5
4
  import { QueryClientProvider } from '@tanstack/react-query';
6
5
  import { RouterProvider } from '@tanstack/react-router';
6
+ import '@xyflow/react/dist/base.css';
7
7
  import { StrictMode } from 'react';
8
8
  import { createRoot } from 'react-dom/client';
9
-
10
- import { router } from './app/router';
11
9
  import { ReportSourcesProvider } from './app/report-sources';
12
- import { AdapterContext } from './data/queries';
10
+ import { router } from './app/router';
13
11
  import { createAdapter } from './data/adapters';
14
- import { createQueryClient } from './data/queries';
15
-
12
+ import { AdapterContext, createQueryClient } from './data/queries';
13
+ import { AtlasThemeProvider } from './ui/theme';
16
14
  const root = document.getElementById('root');
17
15
  if (root) {
18
16
  createRoot(root).render(
@@ -20,9 +18,9 @@ if (root) {
20
18
  <AdapterContext.Provider value={createAdapter()}>
21
19
  <QueryClientProvider client={createQueryClient()}>
22
20
  <ReportSourcesProvider>
23
- <Tooltip.Provider delayDuration={300}>
21
+ <AtlasThemeProvider>
24
22
  <RouterProvider router={router} />
25
- </Tooltip.Provider>
23
+ </AtlasThemeProvider>
26
24
  </ReportSourcesProvider>
27
25
  </QueryClientProvider>
28
26
  </AdapterContext.Provider>