@crvouga/atlas 0.5.0 → 0.6.1

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 (148) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +51 -16
  3. package/dist/core/src/cli.d.ts +1 -1
  4. package/dist/core/src/cli.js +8 -3
  5. package/dist/core/src/config.d.ts +7 -0
  6. package/dist/core/src/config.js +48 -14
  7. package/dist/core/src/run/execute.d.ts +6 -0
  8. package/dist/core/src/run/execute.js +4 -2
  9. package/dist/reports/src/doctor.d.ts +1 -1
  10. package/dist/reports/src/doctor.js +3 -1
  11. package/dist/reports/src/http-store.d.ts +4 -0
  12. package/dist/reports/src/http-store.js +6 -3
  13. package/dist/reports/src/init.js +24 -16
  14. package/dist/reports/src/live-reporter.d.ts +34 -0
  15. package/dist/reports/src/live-reporter.js +122 -0
  16. package/dist/reports/src/package-managers.d.ts +35 -0
  17. package/dist/reports/src/package-managers.js +23 -0
  18. package/dist/reports/src/report-cli.d.ts +1 -1
  19. package/dist/reports/src/report-cli.js +3 -2
  20. package/dist/reports/src/store-url.js +1 -1
  21. package/dist/runners/src/codecept.d.ts +14 -0
  22. package/dist/runners/src/codecept.js +16 -0
  23. package/dist/runners/src/jest.d.ts +50 -0
  24. package/dist/runners/src/jest.js +52 -0
  25. package/dist/runners/src/mocha.d.ts +40 -0
  26. package/dist/runners/src/mocha.js +39 -0
  27. package/dist/runners/src/node-test.d.ts +28 -0
  28. package/dist/runners/src/node-test.js +44 -0
  29. package/dist/runners/src/publish.d.ts +30 -0
  30. package/dist/runners/src/publish.js +68 -0
  31. package/dist/schema/src/api.d.ts +1 -0
  32. package/dist/schema/src/api.js +1 -1
  33. package/dist/schema/src/cli-results.d.ts +233 -2
  34. package/dist/schema/src/cli-results.js +14 -0
  35. package/dist/schema/src/index.d.ts +2 -0
  36. package/dist/schema/src/index.js +2 -0
  37. package/dist/schema/src/live-run.d.ts +123 -0
  38. package/dist/schema/src/live-run.js +30 -0
  39. package/dist/schema/src/manifest.d.ts +2 -2
  40. package/dist/schema/src/project.d.ts +20 -0
  41. package/dist/schema/src/project.js +10 -0
  42. package/dist/schema/src/report-sources.d.ts +1 -0
  43. package/dist/schema/src/report-sources.js +2 -1
  44. package/dist/schema/src/runs-index.d.ts +1 -1
  45. package/dist/schema/src/server-config.d.ts +84 -0
  46. package/dist/schema/src/server-config.js +12 -0
  47. package/dist/server/src/app.d.ts +10 -0
  48. package/dist/server/src/app.js +148 -10
  49. package/dist/server/src/config.d.ts +1 -1
  50. package/dist/server/src/config.js +20 -3
  51. package/dist/server/src/dev.d.ts +3 -3
  52. package/dist/server/src/dev.js +22 -13
  53. package/dist/server/src/events.d.ts +3 -3
  54. package/dist/server/src/events.js +1 -1
  55. package/dist/server/src/index.d.ts +2 -0
  56. package/dist/server/src/index.js +2 -0
  57. package/dist/server/src/live-runs.d.ts +52 -0
  58. package/dist/server/src/live-runs.js +229 -0
  59. package/dist/server/src/llms.js +17 -8
  60. package/dist/server/src/main.d.ts +1 -1
  61. package/dist/server/src/main.js +7 -5
  62. package/dist/server/src/openapi.d.ts +2 -5
  63. package/dist/server/src/openapi.js +71 -7
  64. package/dist/server/src/projects.d.ts +3 -0
  65. package/dist/server/src/projects.js +154 -0
  66. package/dist/server/src/settings-page.d.ts +9 -0
  67. package/dist/server/src/settings-page.js +93 -0
  68. package/dist/server/src/settings.d.ts +23 -0
  69. package/dist/server/src/settings.js +72 -0
  70. package/dist/server/src/site.d.ts +8 -6
  71. package/dist/server/src/site.js +45 -10
  72. package/dist/server/src/store-api.js +43 -22
  73. package/docs/agents.md +166 -15
  74. package/docs/deploying.md +24 -20
  75. package/docs/dogfooding.md +7 -3
  76. package/docs/extending.md +3 -3
  77. package/docs/integrating.md +19 -11
  78. package/docs/quickstart.md +81 -0
  79. package/docs/reference/cli.md +9 -7
  80. package/docs/reference/core.md +6 -0
  81. package/docs/reference/http-api.md +498 -2
  82. package/docs/reference/reports.md +84 -1
  83. package/docs/reference/runners.md +169 -0
  84. package/docs/reference/schema.md +120 -0
  85. package/docs/reference/server.md +154 -14
  86. package/docs/report-backends.md +8 -8
  87. package/docs/report-contract.md +7 -7
  88. package/docs/reporting-issues.md +3 -3
  89. package/docs/runners.md +69 -0
  90. package/docs/server.md +155 -25
  91. package/docs/statecharts.md +22 -20
  92. package/llms-full.txt +1079 -126
  93. package/llms.txt +7 -4
  94. package/package.json +33 -9
  95. package/packages/core/README.md +3 -1
  96. package/packages/runners/README.md +6 -0
  97. package/packages/runners/compat/codecept.cjs +2 -0
  98. package/packages/runners/compat/jest.cjs +2 -0
  99. package/packages/runners/compat/mocha.cjs +2 -0
  100. package/packages/schema/schemas/atlas-change.schema.json +4 -0
  101. package/packages/schema/schemas/hello-event.schema.json +4 -0
  102. package/packages/schema/schemas/live-run-start.schema.json +42 -0
  103. package/packages/schema/schemas/live-run-update.schema.json +93 -0
  104. package/packages/schema/schemas/live-run.schema.json +225 -0
  105. package/packages/schema/schemas/project-slug.schema.json +8 -0
  106. package/packages/schema/schemas/project.schema.json +22 -0
  107. package/packages/schema/schemas/projects.schema.json +33 -0
  108. package/packages/schema/schemas/server-config.schema.json +14 -0
  109. package/packages/schema/schemas/server-editable-settings.schema.json +132 -0
  110. package/packages/schema/schemas/server-settings-snapshot.schema.json +285 -0
  111. package/packages/server/openapi.json +5101 -779
  112. package/packages/visualizer/README.md +13 -13
  113. package/packages/visualizer/bin/atlas-client.mjs +176 -0
  114. package/packages/visualizer/bin/atlas-visualizer.mjs +2 -175
  115. package/packages/visualizer/src/app/AppShell.tsx +26 -17
  116. package/packages/visualizer/src/app/report-sources.tsx +2 -1
  117. package/packages/visualizer/src/components/ActivityIndicator.module.css +4 -0
  118. package/packages/visualizer/src/components/ActivityIndicator.tsx +6 -0
  119. package/packages/visualizer/src/components/Header.module.css +101 -227
  120. package/packages/visualizer/src/components/Header.tsx +79 -41
  121. package/packages/visualizer/src/components/ProjectPicker.tsx +64 -0
  122. package/packages/visualizer/src/data/adapters/report-backend.test.ts +3 -2
  123. package/packages/visualizer/src/data/adapters/report-backend.ts +1 -1
  124. package/packages/visualizer/src/data/queries.ts +14 -7
  125. package/packages/visualizer/src/evidence/api.ts +2 -1
  126. package/packages/visualizer/src/evidence/check-detail.tsx +19 -4
  127. package/packages/visualizer/src/evidence/document-page.tsx +6 -2
  128. package/packages/visualizer/src/evidence/evidence-context.tsx +17 -12
  129. package/packages/visualizer/src/evidence/evidence.module.css +11 -1
  130. package/packages/visualizer/src/evidence/feature-page.tsx +7 -3
  131. package/packages/visualizer/src/evidence/model-page.tsx +6 -2
  132. package/packages/visualizer/src/evidence/project-home.tsx +45 -0
  133. package/packages/visualizer/src/evidence/recording-page.tsx +4 -1
  134. package/packages/visualizer/src/evidence/spec-index.tsx +2 -1
  135. package/packages/visualizer/src/evidence/test-filter.tsx +21 -0
  136. package/packages/visualizer/src/live/live-query.ts +21 -0
  137. package/packages/visualizer/src/live/live-runs.tsx +69 -0
  138. package/packages/visualizer/src/live/live-stream.ts +30 -9
  139. package/packages/visualizer/src/live/report-progress.ts +62 -0
  140. package/packages/visualizer/src/live/test-progress.tsx +34 -0
  141. package/packages/visualizer/src/runtime.ts +17 -1
  142. package/packages/visualizer/src/state/ui-store.ts +3 -2
  143. package/packages/visualizer/src/views/ChartPage.tsx +2 -0
  144. package/packages/visualizer/src/views/ChartTests.tsx +43 -0
  145. package/packages/visualizer/src/views/SidePanel.tsx +2 -0
  146. package/packages/visualizer/vite/atlas-files.test.ts +4 -4
  147. package/packages/visualizer/vite/atlas-files.ts +1 -5
  148. package/packages/visualizer/vite/atlas-server-proxy.ts +2 -1
@@ -1,240 +1,114 @@
1
1
  .header {
2
- display: flex;
3
- align-items: stretch;
4
- gap: 20px;
5
- min-height: var(--header-h);
6
- padding: 0 16px;
7
- background: var(--bg);
2
+ position: relative;
3
+ z-index: 20;
4
+ display: grid;
5
+ grid-template-columns: auto minmax(0, 1fr) auto;
6
+ align-items: center;
7
+ gap: 16px;
8
+ min-height: 54px;
9
+ padding: 8px 16px;
8
10
  border-bottom: 1px solid var(--line);
11
+ background: var(--bg);
9
12
  }
10
-
11
13
  .home {
12
- display: inline-flex;
13
- align-items: center;
14
14
  color: var(--ink);
15
- font-size: 1.0625rem;
15
+ font-size: 15px;
16
16
  font-weight: 700;
17
- letter-spacing: -0.01em;
18
- text-decoration: none;
19
- white-space: nowrap;
20
- }
21
-
22
- .tabs {
23
- display: flex;
24
- align-items: stretch;
25
- gap: 2px;
26
- min-width: 0;
27
- }
28
-
29
- .tabs a {
30
- display: inline-flex;
31
- align-items: center;
32
- gap: 6px;
33
- padding: 0 10px;
34
- color: var(--ink-3);
35
- font-size: 0.9375rem;
36
- font-weight: 500;
37
17
  text-decoration: none;
38
- white-space: nowrap;
39
- }
40
-
41
- .tabs a:hover {
42
- color: var(--ink);
43
- }
44
-
45
- .tabs a[aria-current='page'] {
46
- color: var(--ink);
47
- font-weight: 600;
48
- box-shadow: inset 0 -2px 0 var(--place);
49
- }
50
-
51
- .failing {
52
- min-width: 1.125rem;
53
- padding: 0 0.3rem;
54
- border-radius: var(--pill);
55
- background: var(--failed-soft);
56
- color: var(--failed-ink);
57
- font-size: 0.6875rem;
58
- font-weight: 700;
59
- line-height: 1.125rem;
60
- text-align: center;
61
- }
62
-
63
- .tools {
64
- display: flex;
65
- align-items: center;
66
- gap: 10px;
67
- margin-left: auto;
68
- min-width: 0;
69
- padding: 8px 0;
70
- }
71
-
72
- .live {
73
- display: inline-flex;
74
- align-items: center;
75
- gap: 8px;
76
- color: var(--place);
77
- font-size: 0.8125rem;
78
- font-weight: 600;
79
- white-space: nowrap;
80
- }
81
-
82
- .liveDot {
83
- width: 8px;
84
- height: 8px;
85
- border-radius: 50%;
86
- background: var(--place);
87
- animation: pulse 1.4s var(--ease) infinite;
88
18
  }
89
-
90
- .connection {
91
- display: inline-flex;
92
- align-items: center;
93
- gap: 8px;
94
- color: var(--ink-3);
95
- font-size: 0.8125rem;
96
- font-weight: 600;
97
- white-space: nowrap;
98
- }
99
-
100
- .connectionDot {
101
- width: 8px;
102
- height: 8px;
103
- border-radius: 50%;
104
- background: var(--ink-3);
105
- }
106
-
107
- .connection[data-status='live'] {
108
- color: var(--place);
109
- }
110
-
111
- .connection[data-status='live'] .connectionDot {
112
- background: var(--place);
113
- }
114
-
115
- .connection[data-status='reconnecting'] {
116
- color: var(--failed-ink);
117
- }
118
-
119
- .connection[data-status='reconnecting'] .connectionDot {
120
- background: currentColor;
121
- animation: pulse 1.4s var(--ease) infinite;
122
- }
123
-
124
- @keyframes pulse {
125
- 50% {
126
- opacity: 0.35;
127
- }
128
- }
129
-
130
- .sources,
131
- .select {
132
- height: 2.125rem;
133
- padding: 0 12px;
134
- border: 1px solid var(--line-2);
135
- border-radius: var(--pill);
136
- background: var(--bg);
19
+ .breadcrumbs, .tools { display: flex; align-items: center; min-width: 0; gap: 6px; }
20
+ .breadcrumbs { color: var(--faint); }
21
+ .tools { justify-content: flex-end; }
22
+ .picker { position: relative; display: inline-flex; min-width: 0; max-width: 100%; }
23
+ .picker::after {
24
+ position: absolute;
25
+ right: 10px;
26
+ top: 12px;
27
+ width: 5px;
28
+ height: 5px;
29
+ border-right: 1.5px solid var(--ink-3);
30
+ border-bottom: 1.5px solid var(--ink-3);
31
+ transform: rotate(45deg);
32
+ pointer-events: none;
33
+ content: '';
34
+ }
35
+ .select, .search, .menu > summary, .sources {
36
+ box-sizing: border-box;
37
+ height: 32px;
38
+ border: 1px solid transparent;
39
+ border-radius: 6px;
40
+ background: transparent;
137
41
  color: var(--ink);
138
- font-size: 0.8125rem;
139
- font-weight: 600;
140
- white-space: nowrap;
42
+ font: 500 13px var(--font);
141
43
  cursor: pointer;
142
44
  }
143
-
144
- .sources:hover,
145
- .select:hover {
146
- background: var(--bg-2);
147
- }
148
-
149
- .sources span {
150
- margin-left: 6px;
151
- color: var(--ink-3);
152
- font-weight: 400;
153
- }
154
-
155
- .sources[data-filtered] {
156
- border-color: var(--place);
157
- color: var(--place);
158
- }
159
-
160
- .sources[data-filtered] span {
161
- color: var(--place);
162
- }
163
-
164
- .picker {
165
- display: inline-flex;
166
- }
167
-
168
45
  .select {
169
- max-width: 15rem;
170
- font-weight: 500;
171
- }
172
-
173
- .search {
174
- display: inline-flex;
175
- align-items: center;
176
- gap: 10px;
177
- width: min(15rem, 28vw);
178
- height: 2.125rem;
179
- padding: 0 8px 0 14px;
180
- border: 1px solid transparent;
181
- border-radius: var(--pill);
182
- background: var(--bg-2);
183
- color: var(--ink-3);
184
- font-size: 0.875rem;
185
- font-weight: 500;
186
- text-align: left;
187
- cursor: pointer;
188
- }
189
-
190
- .search kbd {
191
- margin-left: auto;
192
- padding: 0 0.35rem;
193
- border: 1px solid var(--line-2);
194
- border-radius: 0.35rem;
195
- color: var(--ink-3);
196
- font-family: var(--font);
197
- font-size: 0.75rem;
198
- font-weight: 600;
199
- line-height: 1.35rem;
200
- }
201
-
202
- .search:hover,
203
- .search:focus-visible {
204
- border-color: var(--place);
46
+ appearance: none;
47
+ min-width: 0;
48
+ max-width: min(24rem, 30vw);
49
+ padding: 0 28px 0 10px;
50
+ overflow: hidden;
51
+ text-overflow: ellipsis;
52
+ }
53
+ .picker[data-project] .select { max-width: 200px; border-color: var(--line); }
54
+ .select:disabled { color: var(--ink-3); cursor: default; }
55
+ .select:hover:not(:disabled), .search:hover, .menu > summary:hover, .sources:hover { background: var(--bg-2); }
56
+ .select:focus-visible, .search:focus-visible, .menu > summary:focus-visible, .sources:focus-visible, .tabs a:focus-visible {
57
+ outline: 2px solid var(--place);
58
+ outline-offset: 2px;
59
+ }
60
+ .search { display: inline-flex; align-items: center; justify-content: center; width: 32px; padding: 0; color: var(--ink-3); }
61
+ .menu { position: relative; min-width: 0; }
62
+ .menu > summary { display: flex; align-items: center; gap: 8px; padding: 0 10px; list-style: none; white-space: nowrap; }
63
+ .menu > summary::-webkit-details-marker { display: none; }
64
+ .menu[data-tools] > summary { width: 32px; justify-content: center; padding: 0; font-size: 16px; letter-spacing: 1px; }
65
+ .menu[open] > summary { background: var(--bg-2); }
66
+ .dropdown {
67
+ position: absolute;
68
+ z-index: 100;
69
+ top: calc(100% + 8px);
70
+ left: 0;
71
+ min-width: 200px;
72
+ max-width: min(320px, calc(100vw - 32px));
73
+ padding: 6px;
74
+ border: 1px solid var(--line);
75
+ border-radius: 8px;
205
76
  background: var(--bg);
206
- color: var(--ink);
207
- }
208
-
209
- @media (max-width: 760px) {
210
- .header {
211
- flex-wrap: wrap;
212
- gap: 0 16px;
213
- }
214
-
215
- .home {
216
- min-height: 2.75rem;
217
- }
218
-
219
- .tabs {
220
- order: 3;
221
- flex-basis: 100%;
222
- min-height: 2.5rem;
223
- overflow-x: auto;
224
- }
225
-
226
- .tools {
227
- flex: 1;
228
- justify-content: flex-end;
229
- }
230
-
231
- .live,
232
- .connection,
233
- .picker {
234
- display: none;
235
- }
236
-
237
- .search {
238
- width: min(11rem, 40vw);
239
- }
240
- }
77
+ box-shadow: 0 8px 24px #00000014;
78
+ }
79
+ .menu:not([open]) > .dropdown { display: none; }
80
+ .menu[data-tools] .dropdown { left: auto; right: 0; width: 260px; }
81
+ .tabs { display: flex; flex-direction: column; }
82
+ .tabs a { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 10px; border-radius: 4px; color: var(--ink-3); font-size: 13px; text-decoration: none; }
83
+ .tabs a:hover { background: var(--bg-2); color: var(--ink); }
84
+ .tabs a[aria-current='page'] { background: var(--place-soft); color: var(--ink); font-weight: 600; }
85
+ .failing { color: var(--failed-ink); font-size: 12px; }
86
+ .options { display: flex; flex-direction: column; gap: 4px; }
87
+ .optionGroup { display: flex; flex-direction: column; gap: 4px; padding: 6px 4px 10px; border-bottom: 1px solid var(--line); }
88
+ .optionLabel { padding: 0 6px; color: var(--ink-3); font-size: 11px; }
89
+ .optionGroup .picker, .optionGroup .select { width: 100%; max-width: 100%; }
90
+ .optionGroup .select { border-color: var(--line); }
91
+ .sources { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; padding: 0 10px; text-decoration: none; text-align: left; }
92
+ .sources span { color: var(--ink-3); font-weight: 400; }
93
+ .sources[data-filtered], .sources[data-filtered] span { color: var(--place); }
94
+ .connection { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 32px; flex: none; }
95
+ .connectionDot, .liveDot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--ink-3); flex: none; }
96
+ .connection[data-status='live'] .connectionDot { background: var(--passed); }
97
+ .connection[data-status='reconnecting'] .connectionDot { background: var(--failed-ink); }
98
+ .live { display: flex; align-items: center; gap: 6px; padding: 6px 10px; color: var(--ink-3); font-size: 12px; }
99
+ .liveDot { background: var(--place); }
100
+ .notice { margin-top: 4px; padding: 8px 4px 0; border-top: 1px solid var(--line); color: var(--ink-3); font-size: 12px; line-height: 1.5; }
101
+ .notice p { margin: 0 6px 6px; }
102
+ @media (max-width: 700px) {
103
+ .header { gap: 4px 10px; padding: 6px 12px; }
104
+ .home { grid-column: 1; grid-row: 1; }
105
+ .tools { grid-column: 2 / -1; grid-row: 1; }
106
+ .breadcrumbs { grid-column: 1 / -1; grid-row: 2; }
107
+ .breadcrumbs .picker { flex: 1; }
108
+ .breadcrumbs .select { width: 100%; max-width: 100%; }
109
+ .picker[data-project] .select { max-width: min(180px, 43vw); }
110
+ .menu[data-tools] .dropdown { position: fixed; top: 44px; left: 12px; right: 12px; width: auto; max-width: none; }
111
+ }
112
+
113
+ .picker[data-running] .select { padding-left: 28px; }
114
+ .pickerActivity { position: absolute; left: 9px; top: 10px; display: flex; pointer-events: none; }
@@ -1,18 +1,23 @@
1
1
  import { Link, useLocation, useNavigate, useSearch } from '@tanstack/react-router';
2
- import { useEffect, useMemo, useState } from 'react';
2
+ import { useEffect, useMemo, useRef, type ReactNode } from 'react';
3
3
 
4
4
  import { useAtlas } from '../app/atlas-context';
5
- import { commandKey, setFinderOpen } from '../find/open';
5
+ import { setFinderOpen } from '../find/open';
6
6
  import type { AtlasView } from '../data/model';
7
7
  import { LATEST } from '../data/queries';
8
8
  import { useEvidence } from '../evidence/evidence-context';
9
9
  import { plural } from '../evidence/format';
10
10
  import { excludedCount } from '../evidence/sources-panel';
11
- import { eventsAddress, listenLive, useLiveStatus, type ReportAnnouncement } from '../live/live-stream';
11
+ import { useLiveStatus } from '../live/live-stream';
12
12
  import { kindOfAddress, outlinePath, SPEC_KINDS } from '../spec-kind';
13
+ import { runtimeConfig } from '../runtime';
13
14
  import { useUiStore } from '../state/ui-store';
14
15
  import { DataIssues } from './DataIssues';
16
+ import { ActivityIndicator } from './ActivityIndicator';
15
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';
16
21
 
17
22
  const dateTime = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
18
23
 
@@ -69,38 +74,15 @@ function LiveRuns() {
69
74
  );
70
75
  }
71
76
 
72
- /** Is this page being kept up to date by its server, and what arrived last. Nothing at all for a static site. */
77
+ /** A fixed-size connection indicator; incoming results never change the header's width. */
73
78
  function ServerConnection() {
74
79
  const status = useLiveStatus();
75
- const [arrival, setArrival] = useState<ReportAnnouncement | null>(null);
76
- useEffect(() => {
77
- const url = eventsAddress();
78
- if (!url) return;
79
- let timer: ReturnType<typeof setTimeout> | undefined;
80
- const stop = listenLive(url, (event) => {
81
- if (event.type !== 'report' || !event.report.added.length) return;
82
- setArrival(event.report);
83
- clearTimeout(timer);
84
- timer = setTimeout(() => setArrival(null), 8_000);
85
- });
86
- return () => {
87
- clearTimeout(timer);
88
- stop();
89
- };
90
- }, []);
91
80
  if (status === 'off') return null;
92
- const first = arrival?.added[0];
93
- const failed = arrival?.added.reduce((sum, layer) => sum + (layer.counts?.failed ?? 0), 0) ?? 0;
81
+ const label = status === 'live' ? 'Live updates connected' : status === 'connecting' ? 'Connecting to live updates' : 'Live updates disconnected; reconnecting';
94
82
  return (
95
- <span className={styles.connection} data-status={status} role="status" title={status === 'live' ? 'This page updates as results reach the server.' : status === 'connecting' ? 'Connecting to the server.' : 'The connection to the server was lost. Trying again; the report on screen is the last one received.'}>
83
+ <span className={styles.connection} data-status={status} role="status" aria-label={label} title={label}>
96
84
  <span className={styles.connectionDot} aria-hidden="true" />
97
- {arrival && first
98
- ? `${first.producer}${first.environment ? ` · ${first.environment}` : ''}${arrival.addedTotal > 1 ? ` +${arrival.addedTotal - 1}` : ''}${failed ? ` · ${plural(failed, 'failure', 'failures')}` : ' · new results'}`
99
- : status === 'live'
100
- ? 'Live'
101
- : status === 'connecting'
102
- ? 'Connecting'
103
- : 'Reconnecting'}
85
+ <span className="sr-only">{label}</span>
104
86
  </span>
105
87
  );
106
88
  }
@@ -124,6 +106,7 @@ export function Header() {
124
106
  const { pathname } = useLocation();
125
107
  const kind = kindOfAddress(pathname);
126
108
  const product = productName(view?.title);
109
+ const hasCharts = evidence.files.some(isStatechartFile);
127
110
  useEffect(() => {
128
111
  document.title = product;
129
112
  }, [product]);
@@ -131,37 +114,92 @@ export function Header() {
131
114
  () =>
132
115
  SPEC_KINDS.map((entry) => {
133
116
  const files = evidence.files.filter(entry.accepts);
134
- return { kind: entry, files: files.length, failing: files.filter((file) => (evidence.view.specs.get(outlinePath(file))?.summary.counts.failed ?? 0) > 0).length };
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 };
135
118
  }).filter((tab) => tab.files > 0 || tab.kind === kind),
136
119
  [evidence.files, evidence.view, kind]
137
120
  );
138
121
  return (
139
122
  <header className={styles.header}>
140
123
  <Link to="/" search={(prev) => runOnly(prev)} className={styles.home}>
141
- {product}
124
+ Atlas
142
125
  </Link>
143
- <nav className={styles.tabs} aria-label="Spec type">
126
+ <div className={styles.breadcrumbs}>
127
+ <HeaderMenu label="Spec type" working={tabs.some(tab => tab.running)} caption={kind?.label ?? (hasCharts ? 'Statecharts' : 'Specs')}>
128
+ <nav className={styles.tabs} aria-label="Spec type" onClick={event => event.currentTarget.closest('details')?.removeAttribute('open')}>
144
129
  <Link to="/" search={(prev) => runOnly(prev)} aria-current={pathname.startsWith('/specs/') ? undefined : 'page'}>
145
- Statecharts
130
+ {hasCharts || !evidence.ready ? 'Statecharts' : 'Project reports'}
146
131
  </Link>
147
132
  {tabs.map((tab) => (
148
133
  <Link key={tab.kind.id} to="/specs/$kind" params={{ kind: tab.kind.id }} search={{}} aria-current={tab.kind === kind ? 'page' : undefined}>
149
134
  {tab.kind.label}
135
+ {tab.running && <ActivityIndicator label={`${tab.kind.label} tests running`} />}
150
136
  {tab.failing > 0 && <span className={styles.failing} aria-label={`${plural(tab.failing, tab.kind.one, tab.kind.many)} failing`}>{tab.failing}</span>}
151
137
  </Link>
152
138
  ))}
153
139
  </nav>
140
+ </HeaderMenu>
141
+ <SpecBreadcrumb />
142
+ </div>
154
143
  <div className={styles.tools}>
155
144
  <ServerConnection />
156
- <LiveRuns />
157
- {!kind && view && <RunPicker view={view} />}
158
- {!kind && view && <DataIssues issues={view.issues} />}
159
- <SourcesButton />
160
- <button type="button" className={styles.search} aria-keyshortcuts="Meta+K Control+K" onClick={() => setFinderOpen(true)}>
161
- Find a flow
162
- <kbd>{commandKey()}</kbd>
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} />
163
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>
158
+ <ProjectPicker />
164
159
  </div>
165
160
  </header>
166
161
  );
167
162
  }
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
+ function SpecBreadcrumb() {
185
+ const evidence = useEvidence();
186
+ const { view } = useAtlas();
187
+ const { pathname } = useLocation();
188
+ const search = useSearch({ strict: false });
189
+ const navigate = useNavigate();
190
+ const kind = kindOfAddress(pathname);
191
+ 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 }));
194
+ 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></>;
205
+ }
@@ -0,0 +1,64 @@
1
+ import { useEffect } from 'react';
2
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
3
+ import { ProjectsSchema, type Project } from '@crvouga/atlas/schema';
4
+ import { currentProject, runtimeConfig, runtimeUrl } from '../runtime';
5
+ import { eventsAddress, listenLive, useLiveStatus } from '../live/live-stream';
6
+ import { liveInvalidator } from '../live/live-query';
7
+ import styles from './Header.module.css';
8
+
9
+ /** Project changes navigate the page so queries, recordings and event streams cannot carry across projects. */
10
+ export function selectProject(slug: string) {
11
+ const url = new URL(window.location.href);
12
+ url.searchParams.set('project', slug);
13
+ url.hash = '#/';
14
+ window.location.assign(url.href);
15
+ }
16
+
17
+ function useProjects() {
18
+ const address = runtimeUrl(runtimeConfig()?.projectsUrl);
19
+ const client = useQueryClient();
20
+ const streaming = useLiveStatus() === 'live';
21
+ useEffect(() => {
22
+ const events = eventsAddress();
23
+ if (!address || !events) return;
24
+ 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(); };
27
+ }, [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,
30
+ queryFn: async ({ signal }) => {
31
+ const response = await fetch(address!, { signal, cache: 'no-store' });
32
+ if (!response.ok) throw new Error(`Projects unavailable (${response.status})`);
33
+ return ProjectsSchema.parse(await response.json()).projects;
34
+ } });
35
+ }
36
+
37
+ export function ProjectPicker() {
38
+ const address = runtimeUrl(runtimeConfig()?.projectsUrl);
39
+ const selected = currentProject();
40
+ const query = useProjects();
41
+ useEffect(() => {
42
+ if (!selected && query.data?.length && !query.data.some(project => project.slug === 'default')) selectProject(query.data[0]!.slug);
43
+ }, [selected, query.data]);
44
+ if (!address) return null;
45
+ const projects = query.data ?? [];
46
+ 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>;
54
+ }
55
+
56
+ /** One actionable error in the options menu, without duplicating alerts across the navigation. */
57
+ export function ProjectNotice() {
58
+ const query = useProjects();
59
+ 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>;
64
+ }
@@ -78,18 +78,19 @@ describe('report backends', () => {
78
78
  }
79
79
  }
80
80
  stubGlobal('EventSource', Stream);
81
- const adapter = createHttpAdapter('/api/', 'api', './events');
81
+ const adapter = createReportBackend({ id: 'workspace', label: 'Atlas server', type: 'api', baseUrl: '/api/', live: true, eventsUrl: './events' });
82
82
  const receive = vi.fn();
83
83
  const unsubscribe = adapter.subscribe!(receive);
84
84
  const stream = streams[0]!;
85
85
  expect(stream.url).toBe('https://atlas.example.test/api/events');
86
86
  stream.dispatchEvent(new Event('open'));
87
+ expect(receive).toHaveBeenNthCalledWith(1, { scope: 'specs', files: [], runIds: [] });
87
88
  expect(receive).toHaveBeenLastCalledWith({ scope: 'runs', files: [], runIds: [] });
88
89
  stream.dispatchEvent(new MessageEvent('atlas:change', { data: '{"scope":"runs","runIds":["live"]}' }));
89
90
  expect(receive).toHaveBeenLastCalledWith({ scope: 'runs', files: [], runIds: ['live'] });
90
91
  stream.dispatchEvent(new MessageEvent('message', { data: '{broken' }));
91
92
  stream.dispatchEvent(new MessageEvent('message', { data: '{"scope":"unknown"}' }));
92
- expect(receive).toHaveBeenCalledTimes(2);
93
+ expect(receive).toHaveBeenCalledTimes(3);
93
94
  unsubscribe();
94
95
  expect(stream.close).toHaveBeenCalledTimes(1);
95
96
  });
@@ -28,7 +28,7 @@ export function sourceRuns(backend: ReportBackend, runs: RunSummary[]): SourcedR
28
28
 
29
29
  export function createReportBackend(config: RemoteReportSource): ReportBackend {
30
30
  const adapter =
31
- config.live && import.meta.hot
31
+ config.live && !config.eventsUrl && import.meta.hot
32
32
  ? createDevAdapter(config.baseUrl, config.label, config.id)
33
33
  : createHttpAdapter(config.baseUrl, config.type === 'api' ? 'api' : 'static', config.eventsUrl);
34
34
  return {